From 2fc050293da03a0e9e6c561a75ca34e0ebb558bf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 03:31:53 -0400 Subject: [PATCH 1/2] Add the New Zealand spec package for the graph build (G2) Salvage microcosm#821's build/nz/ into the graph era as spec data only. Brought over from #821: the typed bundle (bundle, catalogs, geography, sources, spine, vintages), source_stages.json with the populace-us Build P donor pin, gates.json, geography_spine.json, release_contract.json and an export contract. Left out: the prepare_nz_wff_axiom_inputs stage, its 21 WFF Family catalog columns, the WFF tax-period vintage and every pin to rulespec-nz 3b663b3e (rulespec-nz#117, unmerged). Route (a): only assign_nz_regions and nz_region_geography_gate join the F0 contract-only kernel ids; catalogs.schema.json is unchanged because no remaining column needs decimal128, int16 or nzd. Reference sets are three separate legacy_json resources, so editing one cannot change the digest another set's graph nodes carry: - target_references.json: the D1 MC5 calibration list (8 rows) - precal_references.json: quantile-map destinations, TA population, and the MSD M1/O4 facts the take-up draws calibrate to (6 rows) - holdout_references.json: AS recipients, MSD Accommodation Assistance, Treasury AN 24/01 and the IRD WFF tripwire (6 rows, validation only) Every row is an unactivated placeholder that compile_ledger_target_references refuses. New legacy_json resources: axiom_rules_bindings.json (rulespec-nz 8dc2507d on main, five modules, their SHA-256 and variables, the 2026-27 tax-year period), scenarios.json (S0-S5, V1-V3, one knob each, x52 annualisation), as_rate_bridge.json (reg 17 base rate and reg 18 cutout from engine evaluations, ported from the conformance harness on ops main), benefit_unit_rule.json (MC7), currency_bridge.json (USD->NZD 1.654, IRS 2024 yearly average) and as_area_crosswalk.json (the Work and Income area definitions; TA shares remain a harvest item, rows: []). Thresholds in gates.json read "awaiting D1"; target_profile_coverage requires exactly the calibration families. Docs: rewrite docs/nz-take-up-engine-plan.md around the graph design; scope the DistributionExplorer wording in docs/nz-calibration-targets.md (lines 27, 40, 104, 147) to AS, per the TAWA methodology's "Modelling take-up" section. Invariants (Hypothesis property tests on each validator, plus checks on the committed resources): no resource names a rulespec-nz commit other than ones reviewed on main; every crosswalk TA's shares sum to 1 within 1e-9; every scenario names exactly one method-card row and a tier from {entitlement, calibration}; no hold-out fact reaches calibration or pre-calibration. Differential checks: loader vs direct parse of the calibration set, binding digests vs the committed Axiom input surface, export-contract exclusions vs bound variables, sources.yaml receipts vs the contract files. The NZ golden is regenerated. Co-Authored-By: Claude Opus 5.5 --- changelog.d/nz-spec-package-graph.added.md | 1 + docs/nz-calibration-targets.md | 8 +- docs/nz-take-up-engine-plan.md | 231 ++- .../microcosm/build/nz/as_area_crosswalk.json | 1665 +++++++++++++++++ .../microcosm/build/nz/as_rate_bridge.json | 442 +++++ .../build/nz/axiom_rules_bindings.json | 134 ++ .../microcosm/build/nz/benefit_unit_rule.json | 82 + .../microcosm/build/nz/country_package.json | 106 ++ .../microcosm/build/nz/currency_bridge.json | 30 + .../microcosm/build/nz/export_contract.json | 91 + .../src/microcosm/build/nz/gates.json | 131 ++ .../microcosm/build/nz/geography_spine.json | 23 + .../build/nz/holdout_references.json | 176 ++ .../microcosm/build/nz/precal_references.json | 156 ++ .../microcosm/build/nz/release_contract.json | 54 + .../src/microcosm/build/nz/scenarios.json | 168 ++ .../src/microcosm/build/nz/source_stages.json | 58 + .../src/microcosm/build/nz/spec/bundle.yaml | 16 + .../src/microcosm/build/nz/spec/catalogs.yaml | 121 ++ .../microcosm/build/nz/spec/geography.yaml | 33 + .../src/microcosm/build/nz/spec/sources.yaml | 70 + .../src/microcosm/build/nz/spec/spine.yaml | 10 + .../src/microcosm/build/nz/spec/vintages.yaml | 32 + .../microcosm/build/nz/target_references.json | 195 ++ .../microcosm/build/spec_engine/resolver.py | 15 +- .../engine_free/shared/test_country_spec.py | 2 +- .../shared/test_nz_spec_package.py | 859 +++++++++ .../test_spec_engine_country_bundles.py | 84 + .../shared/test_spec_engine_reemission.py | 2 +- .../shared/test_spec_only_country_packages.py | 31 + .../tests/golden/nz_country_spec.json | 94 + 31 files changed, 5046 insertions(+), 74 deletions(-) create mode 100644 changelog.d/nz-spec-package-graph.added.md create mode 100644 packages/microcosm-build/src/microcosm/build/nz/as_area_crosswalk.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/as_rate_bridge.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/axiom_rules_bindings.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/benefit_unit_rule.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/country_package.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/currency_bridge.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/export_contract.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/gates.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/geography_spine.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/holdout_references.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/precal_references.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/release_contract.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/scenarios.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/source_stages.json create mode 100644 packages/microcosm-build/src/microcosm/build/nz/spec/bundle.yaml create mode 100644 packages/microcosm-build/src/microcosm/build/nz/spec/catalogs.yaml create mode 100644 packages/microcosm-build/src/microcosm/build/nz/spec/geography.yaml create mode 100644 packages/microcosm-build/src/microcosm/build/nz/spec/sources.yaml create mode 100644 packages/microcosm-build/src/microcosm/build/nz/spec/spine.yaml create mode 100644 packages/microcosm-build/src/microcosm/build/nz/spec/vintages.yaml create mode 100644 packages/microcosm-build/src/microcosm/build/nz/target_references.json create mode 100644 packages/microcosm-build/tests/engine_free/shared/test_nz_spec_package.py create mode 100644 packages/microcosm-build/tests/golden/nz_country_spec.json diff --git a/changelog.d/nz-spec-package-graph.added.md b/changelog.d/nz-spec-package-graph.added.md new file mode 100644 index 0000000000..748f9e1cc3 --- /dev/null +++ b/changelog.d/nz-spec-package-graph.added.md @@ -0,0 +1 @@ +Add the New Zealand spec package (`build/nz/`) laid out for the graph build: the populace-us Build P donor pin, region-layer geography contract, gates (thresholds await D1), release and export contracts, three separate reference sets (calibration, pre-calibration, hold-out; every row an unactivated placeholder), and `legacy_json` resources for the Axiom rules bindings at a rulespec-nz main commit, the scenario grid, the reg 17/18 rate bridge, the benefit-unit rule, the USD to NZD bridge and the territorial-authority to Accommodation Supplement area crosswalk (shares still a harvest item). Salvaged from #821 without its WFF Budget-transport stage or its pins to an unmerged rulespec-nz commit; `assign_nz_regions` and `nz_region_geography_gate` join the F0 contract-only kernel ids. diff --git a/docs/nz-calibration-targets.md b/docs/nz-calibration-targets.md index 3b42c7d7af..e009d38868 100644 --- a/docs/nz-calibration-targets.md +++ b/docs/nz-calibration-targets.md @@ -24,7 +24,7 @@ Ranked by calibration value for a dollar-level take-up estimate. **$** = enables | 2 | **Benefit expenditure by program, in dollars** | MSD *Annual Report 2025* — *Benefits or Related Expenses* schedule | $ | The dollar control totals: NZS $23.19B, Jobseeker+Emergency $4.64B, Accommodation Assistance $2.23B, SLP $2.67B, Sole Parent $2.26B, Hardship $0.75B, WEP $0.56B (2024/25). Explicit CC BY 4.0. [FETCH-OK] | | 3 | **Benefit recipient counts by benefit × age × ethnicity × duration × region** | MSD *Quarterly Benefit Fact Sheets* (Mar 2026) | # | Recipient marginals for every main benefit + supplementary (AS, DA, TAS). National→region→TA→service-centre. Count-only. [FETCH-OK] | | 4 | **Working for Families: expenditure, families, average claim by credit type** | IRD *Working for Families statistics Sept 2025* | $ / # | WfF dollar + count margins by FTC/IWTC/MFTC, family size (2020+), recipient income distribution (2020+). [FETCH-OK] | -| 5 | **Modelled dollar amounts per program by income position** | Treasury *DistributionExplorer* data CSVs (HES24 + BEFU25, TY25–29) | $ / # | TAWA-modelled $ per program (AS, Core Benefits, FTC, IWTC, MFTC, BestStart, WFF, WEP, FamilyBoost, NZ Super, Income Tax) × Household/Family/Individual × income quantile/band, with weighted population per cell. The **full-entitlement denominator** for the take-up gap. MIT. [FETCH-OK] | +| 5 | **Modelled dollar amounts per program by income position** | Treasury *DistributionExplorer* data CSVs (HES24 + BEFU25, TY25–29) | $ / # | TAWA-modelled $ per program (AS, Core Benefits, FTC, IWTC, MFTC, BestStart, WFF, WEP, FamilyBoost, NZ Super, Income Tax) × Household/Family/Individual × income quantile/band, with weighted population per cell. AS values are assigned by TAWA's take-up model, so for AS this is an independent comparison, not full entitlement; TAWA assumes full take-up only for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). MIT. [FETCH-OK] | | 6 | **Age × sex × region / territorial authority population** | Stats NZ *Subnational population estimates at 30 June 2025* (2023-base) | # | Primary demographic reweighting frame: 16 regions / 67 TAs / 21 Auckland boards by age×sex. [SNIPPET; prior-quarter direct xlsx FETCH-OK] | | 7 | **Accommodation Supplement recipients + average payment** | MSD Fact Sheets (counts, by W&I region/TA) + Annual Report (Accommodation Assistance $2.23B) | $ / # | AS is the take-up wedge (~44% receipt among potentially eligible). Count × implied average payment = AS dollar base. [FETCH-OK] | | 8 | **Household composition / family type** | Stats NZ 2023 Census — families, households and housing | # | Household/family-type marginals (1-family 67.7%; 1,780,527 total households). [SNIPPET] + ArcGIS/DataFinder [FETCH-OK for hub] | @@ -37,7 +37,7 @@ Ranked by calibration value for a dollar-level take-up estimate. **$** = enables | 15 | **Minimum wage series + average weekly earnings** | MBIE minimum wage ($23.95 from Apr 2026) + Stats NZ QES/LEED | $ | Anchors the bottom of the earnings distribution and the national/TA earnings margin. [FETCH-OK min wage; SNIPPET QES/LEED] | **Dollar-level calibration design (numerator/denominator):** -- **Denominator (full-entitlement modelled $):** Treasury DistributionExplorer CSVs (#5) — dollars per program by income position, assuming full take-up. +- **Denominator (full-entitlement modelled $):** our own rules × calibrated population. Treasury DistributionExplorer CSVs (#5) are TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement, for AS; TAWA assumes full take-up only for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). - **Numerator (actual $ paid):** MSD Annual Report expenditure schedule (#2) and AN24/01 fiscal totals (#10); IRD WfF expenditure (#4) for in-work credits. - **Average payment** = program expenditure (#2) ÷ recipient count (#3) distributes dollars across the calibrated population. - **Take-up gap** = actual paid vs full-entitlement modelled, anchored empirically by the WfF ~87% and AS ~44% take-up studies (M5/M6 below). @@ -101,7 +101,7 @@ Notes: **I1, I3, I4 are the load-bearing IRD targets.** IRD income tables are ** ## 4. Treasury -Public analysis ships as **code + data on GitHub** and interactive tools. **Treasury outputs assume full take-up** — they are the **denominator** of the take-up gap, not actuals. +Public analysis ships as **code + data on GitHub** and interactive tools. DistributionExplorer (T1) outputs are TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement, for AS and for core benefits (paid only to administrative recipients); TAWA assumes full take-up only for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). They are not actuals. | # | Dataset | Access | Contents | Period | Licence | Status | |---|---|---|---|---|---|---| @@ -144,7 +144,7 @@ Also: `github.com/Treasury-Analytics-and-Insights/Calibration` (MIT, 2018) — " 3. **AS by Area 1–4 is not a standing series** — need TA→Area crosswalk (H4) or OIA; known break at 2027-04-01 (H4c redraw). 4. **IRRS dollars unconfirmed in open dashboard** — source from Kāinga Ora/Vote Housing. 5. **Benefit expenditure is annual + national only** — regional dollars must assume uniform average payment within benefit (flag as modelling assumption). -6. **Treasury distributional outputs assume full take-up** — denominator, not actuals; never calibrate to them as actuals. +6. **Treasury DistributionExplorer is TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement.** Only its WFF credits assume full take-up. Never actuals, never a calibration target. 7. **FamilyBoost unvalidatable** against admin data today. 8. **Household/family projections stale (2013-base)** — use census counts. 9. **Portal migration risk**: Infoshare → ADE Time Series (April 2027) will change URLs/schemas; pin to file CDNs. diff --git a/docs/nz-take-up-engine-plan.md b/docs/nz-take-up-engine-plan.md index 9fa900b8c2..51e5b84edd 100644 --- a/docs/nz-take-up-engine-plan.md +++ b/docs/nz-take-up-engine-plan.md @@ -1,66 +1,173 @@ -# microcosm-nz v1 plan: the New Zealand dollar take-up engine +# New Zealand take-up engine: the graph build -Working plan for [epic #343](https://github.com/PolicyEngine/microcosm/issues/343). Companion inventory: [nz-calibration-targets.md](./nz-calibration-targets.md). Rules: [rulespec-nz](https://github.com/TheAxiomFoundation/rulespec-nz) through the Frame `RulesEngine` protocol via the Axiom adapter (shared with the Belgium pilot under #259 — NZ adds coverage, not adapter code). +Working plan for [epic #343](https://github.com/PolicyEngine/microcosm/issues/343). +Companion inventory: [nz-calibration-targets.md](./nz-calibration-targets.md). +Rules: [rulespec-nz](https://github.com/TheAxiomFoundation/rulespec-nz) through +the Frame `RulesEngine` protocol and the Axiom adapter. ## Product definition -For each major NZ transfer, estimate **predicted entitlement dollars** (rules × calibrated population, full take-up) against **actual dollars paid** (administrative expenditure), and present the gap by income percentile and family type against a living-income threshold. The first published cut is the **Accommodation Supplement**, where survey evidence puts receipt among the potentially eligible at ~44% (MSD Income Support Survey 2022, findings pack 7) versus ~87% for Working for Families and ~97% for Best Start — the sharpest quantified take-up wedge in the system. - -## Method (Belgium pilot recipe, recalibrated) - -1. **Donor pool**: the populace-us support pool (57,240 households / 166,302 persons in the BE pilot vintage). Donor records are US support records and are never presented as NZ microdata; support-stratum labels ship with the artifact. -2. **Reweighting**: microcosm-calibrate against the v1 target set below. Two BE-pilot solver lessons apply verbatim: initialize design weights at the destination population scale (or `target_loss_cap` saturates and gradients vanish), and consume total rows, not component+total double counts, from multi-row target tables. -3. **Rules leg**: rulespec-nz composed modules via the Axiom adapter. Child support is excluded from composed outputs until the income-shares re-encode lands (rulespec-nz#74). -4. **Periods**: tax year 2026-27 primary (1 Apr 2026 rates are current across the encoded surface); PPL/rates-rebate use their 1 July steps. - -## v1 target set - -Sixteen margins, all public, drawn from the inventory's ranked list (numbers refer to inventory rows): - -| Margin | Source | Kind | -|---|---|---| -| Age (5-yr bands) × sex × region ERP | Stats NZ subnational estimates 2025 (S7) | count | -| Single-year age national ERP (collapsed bands) | Stats NZ national estimates (S8) | count | -| Household composition / family type | Census 2023 (S2/S3) | count | -| Tenure (own / rent) × region | Census 2023 (S1/S2) | count | -| Individual taxable income by band | IRD I1 | count + $ | -| Age × income band | IRD I1 tab 4 | count | -| Wage/salary distribution | IRD I2 | $ | -| Main-benefit recipients by benefit × age group | MSD M1 | count | -| AS recipients by W&I region | MSD M1d | count | -| NZS/VP recipients | MSD O4 | count | -| WfF recipient families + expenditure by credit type | IRD I3 | count + $ | -| Benefit expenditure by program | MSD M2 (2024/25 schedule) | $ | -| Rent distribution by TA × bedrooms | MBIE H3 | $ | -| Student loan borrowers | IRD I4 | count | -| Minimum/average earnings anchors | MBIE O1 + QES S12 | $ | -| Ethnicity × age × region (equity cuts) | Census O5 | count | - -Deliberate non-targets: Treasury DistributionExplorer values (full-entitlement modelled — that's the comparison output, not a calibration input); household projections (stale 2013-base); anything IDI-derived. - -## The joint-income problem - -NZ publishes no couple/family joint income distribution (the inventory's top structural gap). The WfF and AS abatement surfaces depend on it. v1 strategy: inherit the donor pool's within-household income correlation structure, calibrate the individual margins hard, then check the implied family-income distribution against the one published conditional slice we have — the IRD WfF recipient-family income distribution (I3, 2020+). If the implied distribution misses that slice materially, escalate to a copula adjustment as a general microcosm-fit operator (never NZ-specific code). - -## Validation gates (phase 2, before any published number) - -1. **Per-case**: rulespec-nz vs Treasury IncomeExplorer on the AN25-01 stylized families; the Python `emtr` repo's `test/ref/emtr_output_{1..6}.csv` worked examples (28 columns including `as_amount`, `ftc_abated`, `iwtc_abated`, `winter_energy`, `emtr`) are ready-made fixtures. -2. **Population EMTR**: reproduce AN25-01's distribution — 94% of individuals below 50% EMTR; ~13% of couple-parent and ~30% of sole-parent families above — within a documented tolerance. Note AN25-01 excludes student-loan repayments from EMTRs; match that treatment in the comparison run. -3. **Aggregates**: WfF, main benefits, NZS, AS totals vs the M2/I3 dollar schedules (expect gaps = take-up, which is the product — so the gate is on *gross entitlement vs DistributionExplorer*, and on demographics vs Stats NZ, not on matching actual spend). -4. **Upstream gate**: rulespec-nz#73 (verified value fixes) merged. - -## Take-up computation - -- **Full-entitlement denominator**: our own rules × population (cross-checked against DistributionExplorer's modelled dollars — same full-take-up assumption, independent stack). -- **Actual numerator**: M2 expenditure by program (+ I3 for WfF; T4/T8 as secondary anchors). -- **Distributional actuals**: recipient counts (M1) × implied average payment, with the uniform-average-payment assumption flagged. -- **Outputs**: dollar gap by program; gap by income percentile × family type; AS gap against the ~44%/~87%/~97% survey anchors; living-income overlay. -- **AS specifics**: entitlement needs rents (H3 bond data by TA/bedrooms), the Area 1–4 crosswalk (H4), and area maxima (H4b). The April 2027 area redraw + homeowner 30%→40% entry change (H4c) is a known series break — encode both vintages when 2027 rules land. - -## Risks / honesty constraints - -- Donor-pool provenance: every output carries the US-support-stratum banner; nothing is presented as NZ microdata. -- Joint-income imputation is the weakest link; the WfF-recipient-slice check is the tripwire. -- FamilyBoost has no admin statistics — modelled only, flagged. -- Regional dollar splits assume uniform average payment within program. -- legislation.govt.nz WAF-blocks scripted fetch; rules provenance rides on rulespec-nz's corpus pins, not live fetches. +For each major New Zealand transfer, estimate **predicted entitlement dollars** +(rules × calibrated population, full take-up) against **actual dollars paid** +(administrative expenditure), and present the gap by income percentile and +family type. The first cut is the **Accommodation Supplement** (AS). Survey +evidence puts receipt among the potentially eligible at about 44% (MSD Income +Support Survey 2022, findings pack 7); an IDI-based estimate puts Working for +Families (WFF) take-up at about 87% in 2020 (McLeod and Wilson 2022, targets +inventory M5). + +## Status: 7 October 2026 + +The spec-only [NZ country package](../packages/microcosm-build/src/microcosm/build/nz/country_package.json) +compiles through the shared country seam. It is a declared layout, not a +calibrated population or a release: every target reference is an unactivated +placeholder, the threshold-bearing gates read "awaiting D1", and no NZ graph +node exists yet. Donor records are US support records and are never presented +as New Zealand microdata. + +The package replaces the stage-era scaffold of +[#821](https://github.com/PolicyEngine/microcosm/pull/821). It keeps #821's +donor pin, the region-layer geography contract, the release contract and the +gate set. It leaves out the WFF official-Budget transport stage and every pin +to rulespec-nz `3b663b3e` (rulespec-nz#117, unmerged); the #821 branch +`codex/nz-transport-microcosm` remains the salvage source for that WFF work. +The two region-geography kernel ids, `assign_nz_regions` and +`nz_region_geography_gate`, join the F0 contract-only vocabulary in +`spec_engine/resolver.py`; they close references and are never executable. + +## How the build runs + +The whole v0 build, from the donor file to the AS gap bands, is planned as +one `microcosm.graph.Graph` run with `run_graph` over a content store. There +is no `nz_runtime/` package: kernels are country-neutral (`transport.*`, +`concepts.*`, `takeup.*`, `targets.*`, `gates.*`, `export.*`, +`diagnostics.*`, `simulate.*`), and everything New Zealand-specific is spec +data in `build/nz/`. "nz" appears only in node ids and spec data. + +Version layout: + +``` +nz.create (CREATE) ── nz.open (FILTER keep-all: transport, geography, receipt layer, targets) + ├── nz.calibrate (REWEIGHT; no members) + │ ├── nz.as (FILTER: AS inputs + reg 17/18 bridge) + │ │ └── nz.scn.S0 … nz.scn.S5 (FILTER each) + │ ├── nz.validate (FILTER: WFF tripwire, parked) + │ └── nz.terminal (FILTER: bands, diagnostics, gates, export) + └── nz.v.V1 … nz.v.V3 (FILTER each: variant → calibrate → AS → gap) +``` + +- **CREATE builds the benefit units.** Entity ids and memberships are + structural columns, so the NZ `family` benefit unit is built inside CREATE, + with design weights installed at the Stats NZ population scale. +- **`nz.calibrate` has no members.** Scenarios, validation and terminal + nodes hang off FILTER branches, so adding one never re-keys calibration. +- **Hold-out by ancestry.** No ancestor of a calibration node may read an AS + output, the hold-out references, or the hold-out Chronicle artifact. The + composer package (G6) adds a test over the compiled graph's predecessors + that checks this. +- **Spec values enter node params.** Each node carries the resolved values it + reads and a digest of where they came from; no kernel's implementation hash + binds the whole NZ spec fingerprint, which would re-key every spec-reading + node on any spec edit. + +## Spec resources + +| Resource | Holds | Read by (planned nodes) | Method card | +|---|---|---|---| +| `source_stages.json`, `spec/sources.yaml` | the populace-us Build P donor pin (revision, SHA-256, byte size) | `nz.create` | MC4 | +| `benefit_unit_rule.json` | adult + partner + dependent children; other adults form their own unit; refusals | `nz.create` | MC7 | +| `currency_bridge.json` | USD→NZD 1.654 (IRS 2024 yearly average), one multiplication, 2 dp | `nz.transport.currency` | MC4 | +| `precal_references.json` | quantile-map destinations (IRD wage bands, MBIE rents, Stats NZ financial net worth), TA population, MSD M1 and O4 for take-up draws | `nz.facts.precal` | MC4, MC6, MC8–MC10 | +| `as_area_crosswalk.json` | TA → AS area population shares; Work and Income area definitions | `nz.geo.support` | MC9 | +| `axiom_rules_bindings.json` | rulespec-nz pin, five module bindings, variables, the `2026-27` tax-year period | every Axiom-bound node | MC1 | +| `target_references.json` | the calibration set (MC5 list) | `nz.targets.compile` | MC5 | +| `as_rate_bridge.json` | reg 17 base rate and reg 18 cutout composition | `nz.as.bridge.*` | MC11 | +| `scenarios.json` | S0–S5, V1–V3, knobs, gap conventions (×52) | scenario and variant branches | MC3, MC12 | +| `holdout_references.json` | AS comparators and the WFF tripwire | gap and validation nodes only | MC2, MC5 | +| `gates.json` | the gate battery; thresholds await D1 | `nz.gates.calibrated` | MC15 | +| `export_contract.json` | the closed export contract | export nodes | — | + +The three reference sets are separate files on purpose. A node carries the +digest of the resource it reads, so a hold-out edit cannot move a +calibration-side key, and a new calibration target does not re-run +transport. + +### Reference sets + +- **Calibration (MC5):** region × age × sex population, census household + composition, census family type, tenure × region, IRD taxable-income bands + (counts and totals), MSD main-benefit recipients, NZ Super and Veterans + Pension recipients. +- **Pre-calibration:** IRD wage and salary bands, MBIE bond rents by TA, Stats + NZ household financial net worth, TA population, and the same MSD recipient + facts as the calibration set (the take-up draws calibrate to them). +- **Hold-out:** AS recipients by Work and Income region, MSD "Accommodation + Assistance" expenditure 2024/25, Treasury AN 24/01's AS fiscal total, and + the IRD WFF recipient-family counts and income distribution. + +Three #821 references are outside the D1 default set and were not carried +over: national single-year age, student-loan borrowers, and census ethnicity +by age and region. D1 can add them back from the #821 +branch. + +### The reg 17/18 bridge + +The AS module takes the reg 17 base rate and the reg 18 non-beneficiary +cutout as inputs. Until rulespec-nz encodes those regulations, +`as_rate_bridge.json` composes them from engine evaluations of the bound +main-benefit and family-tax-credit modules, porting the statutory branch of +the IncomeExplorer conformance harness. This is the one sanctioned exception +to "real Axiom runtime only" (method card amendment MC11a) and needs Max's +MC11 ruling. + +The sole-parent cutout cannot be a zero-solve on the unit's own Jobseeker +schedule. The engine applies Income Test 1 to a single person with dependent +children, while the harness pairs the single-with-children rate with the +Income Test 3 slope. The bridge recovers the threshold, the slope and the +gross rate from three engine evaluations instead. Golden-08's base rate +(673.846923…) and cutout (905.028571…) are the differential expectations. + +### What is still open + +- **Crosswalk shares.** The Work and Income area definitions list 2017 + Statistical Area Units, not TAs. Shares need an area-unit to TA concordance + and area-unit populations; the crosswalk ships with `rows: []` and + `status: requires_harvest`, and the geography support must refuse it until + rows exist. +- **Dependent-child rule.** rulespec-nz encodes no general dependent-child + definition at the pin, so the age limit is spec data awaiting D1 (null + until ruled). +- **Engine pin.** The hub pins the Axiom engine at M0 (MC1). +- **Thresholds.** MC15's per-family fit, effective-sample-size floor and + weight-ratio threshold land with G8 after D1. +- **Superannuitants.** The harness has no NZ Super or Veteran's Pension + base-rate branch; the central run excludes and counts superannuitant units + unless reg 17 can be implemented from its text, and S5 carries the + alternative. + +## Validation + +1. **Per case.** rulespec-nz against Treasury IncomeExplorer on the + conformance cases; golden-08 is the AS differential. +2. **WFF tripwire.** The IRD WFF recipient-family income distribution (I3, + 2020 onward) is the one published conditional slice of the joint-income + distribution. It stays held out, and its comparison is parked until the + WFF modules are bound. +3. **Comparators.** The AS gap compares modelled full-entitlement dollars + with MSD's Accommodation Assistance line (after its scope is confirmed) + and AN 24/01. Treasury DistributionExplorer is TAWA-modelled; AS values + assigned by TAWA's take-up model; an independent comparison, not full + entitlement. + +## Risks and honesty constraints + +- Donor provenance: every output carries the US-support-stratum provenance; + nothing is presented as New Zealand microdata. +- Joint income is imputed from donor structure; the WFF slice is the + tripwire. +- Housing costs: MBIE bond rents may describe new tenancies rather than the + sitting stock (scenario S4); boarders' costs and owners' rates are absent. +- Regional dollar splits assume a uniform average payment within a + programme. +- legislation.govt.nz blocks scripted fetches; rules provenance rides on + rulespec-nz's corpus pins, not live fetches. diff --git a/packages/microcosm-build/src/microcosm/build/nz/as_area_crosswalk.json b/packages/microcosm-build/src/microcosm/build/nz/as_area_crosswalk.json new file mode 100644 index 0000000000..6171b273be --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/as_area_crosswalk.json @@ -0,0 +1,1665 @@ +{ + "version": 1, + "country": "nz", + "method_card_row": "MC9", + "description": "Territorial authority to Accommodation Supplement area crosswalk. Each row gives one territorial authority's population shares across Areas 1-4; the atomic geography support multiplies Chronicle territorial-authority population by these shares, households draw one territorial authority by area unit, and the area columns derive from it. Area membership is defined by Work and Income on 2017 Statistical Area Unit names, not territorial authorities, so a share is the population of the authority's area units in each area divided by the authority's population. Where an authority spans areas the population-weighted split is a declared assumption (MC9).", + "status": "requires_harvest", + "status_reason": "No rows yet. Computing shares needs two sources this package does not hold: (1) a 2017 Statistical Area Unit to territorial authority concordance (the Stats NZ Geographic Areas File 2017 sits behind a DataFinder login; the public Stats NZ ArcGIS Area_Unit_2017 layer carries area-unit codes and names but no territorial-authority attribute, checked 2026-10-07); (2) area-unit usually resident population. Until rows exist the geography support kernel must refuse this resource.", + "method": { + "central": { + "id": "as_area", + "rule": "population_weighted_split", + "weights": "usually resident population of each territorial authority's 2017 area units in each Accommodation Supplement area" + }, + "alternative": { + "id": "as_area_alt", + "scenario": "S3", + "rule": "modal_area", + "description": "assign the whole territorial authority to the area holding its largest population share; a tie is refused for review, never broken silently" + }, + "share_tolerance": 1e-09 + }, + "ta_vintage": "ta_2025", + "effective_period": { + "definitions_from": "2018-04-01", + "model_period": { + "start": "2026-04-01", + "end": "2027-03-31" + }, + "note": "The 1 April 2018 definitions cover the whole model year. The Budget 2025 area redraw takes effect in April 2027, after it (targets doc H4c, a search-snippet citation)." + }, + "area_definitions": { + "source": { + "publisher": "Work and Income (Ministry of Social Development)", + "title": "Definitions of areas", + "url": "https://www.workandincome.govt.nz/map/deskfile/extra-help-information/accommodation-supplement-tables/definitions-of-areas.html", + "resolved_url": "https://map.workandincome.govt.nz/map/deskfile/extra-help-information/accommodation-supplement-tables/definitions-of-areas.html", + "retrieved_on": "2026-10-07", + "retrieved_sha256": "ea591e68bb24e5b62f5cc60619dbc75da314e7a29450e882e9301efe2c0768b0", + "retrieved_note": "SHA-256 of the HTML bytes fetched; the transcription below is every comma-separated location under each area heading, in page order.", + "legislation": "Social Security Act 2018 Schedule 4 Part 7 clause 8 (cited on the page)", + "unit": "Statistical Area Units 2017 (Stats NZ), named by the page as \"not equivalent to postal address suburbs, towns or cities\"" + }, + "areas": { + "1": { + "locations": [ + { + "heading": "Central Auckland Zone", + "statistical_area_units_2017": [ + "Abbotts Park", + "Akarana", + "Arch Hill", + "Auckland Central East", + "Auckland Central West", + "Auckland Harbourside", + "Avondale South", + "Avondale West", + "Balmoral", + "Blockhouse Bay", + "Eden Terrace", + "Ellerslie North", + "Ellerslie South", + "Epsom Central", + "Epsom North", + "Epsom South", + "Fairburn", + "Ferndale", + "Freemans Bay", + "Glen Innes East", + "Glen Innes North", + "Glen Innes West", + "Glenavon", + "Glendowie", + "Grafton East", + "Grafton West", + "Grey Lynn East", + "Grey Lynn West", + "Hamlin", + "Herne Bay", + "Hillsborough East", + "Hillsborough West", + "Kingsland", + "Kohimarama East", + "Kohimarama West", + "Lynfield North", + "Lynfield South", + "Maungawhau", + "Meadowbank North", + "Meadowbank South", + "Mission Bay", + "Mt Albert Central", + "Mt Eden East", + "Mt Eden North", + "Mt Eden South", + "Mt Hobson", + "Mt St John", + "Mt Wellington Domain", + "Mt Wellington North", + "Mt Wellington South", + "Mt Wellington West", + "New Windsor", + "Newmarket", + "Newton", + "One Tree Hill Central", + "One Tree Hill East", + "Onehunga North East", + "Onehunga North West", + "Onehunga South East", + "Onehunga South West", + "Orakei North", + "Orakei South", + "Oranga", + "Otahuhu East", + "Otahuhu North", + "Otahuhu West", + "Owairaka East", + "Owairaka West", + "Panmure Basin", + "Parnell East", + "Parnell West", + "Penrose", + "Point Chevalier East", + "Point Chevalier South", + "Point Chevalier West", + "Point England", + "Ponsonby East", + "Ponsonby West", + "Remuera South", + "Remuera West", + "Roberton", + "Rosebank", + "Royal Oak", + "Sandringham East", + "Sandringham North", + "Sandringham West", + "Sherbourne", + "Springleigh", + "St Heliers", + "St Johns", + "St Lukes", + "St Lukes North", + "St Marys", + "Stonefields", + "Surrey Crescent", + "Tamaki", + "Te Papapa", + "Three Kings", + "Waiata", + "Waikowhai East", + "Waikowhai West", + "Waitaramoa", + "Walmsley", + "Waterview", + "Wesley", + "Westmere" + ] + }, + { + "heading": "Northern Auckland Zone", + "statistical_area_units_2017": [ + "Albany", + "Army Bay", + "Awaruku", + "Bayswater", + "Beachhaven North", + "Beachhaven South", + "Birkdale North", + "Birkdale South", + "Birkenhead East", + "Browns Bay", + "Campbells Bay", + "Castor Bay", + "Chelsea", + "Crown Hill", + "Dairy Flat-Redvale", + "Fairview", + "Forrest Hill", + "Glamorgan", + "Glendhu", + "Glenfield Central", + "Glenfield North", + "Greenhithe", + "Gulf Harbour", + "Hatfields Beach", + "Hauraki", + "Kaipatiki", + "Kauri Park", + "Lake Pupuke", + "Long Bay", + "Mairangi Bay", + "Manly", + "Monarch Park", + "Mt Victoria", + "Murrays Bay", + "Narrow Neck", + "North Harbour East", + "North Harbour West", + "Northcote South", + "Northcross", + "Oaktree", + "Ocean View", + "Orewa", + "Orewa West", + "Paremoremo East", + "Paremoremo West", + "Pinehill", + "Red Beach East", + "Red Beach West", + "Rothesay Bay", + "Seacliffe", + "Silverdale Central", + "Silverdale North", + "Silverdale South", + "Stanley Bay", + "Stanmore Bay East", + "Stanmore Bay West", + "Stillwater", + "Sunnybrae", + "Sunnynook", + "Takapuna Central", + "Target Road", + "Torbay", + "Tuff Crater", + "Unsworth Heights", + "Vipond", + "Wade Heads", + "Waiake", + "Waiwera", + "Weiti River", + "Westlake", + "Windsor Park", + "Windy Ridge", + "Witheford" + ] + }, + { + "heading": "Pukekohe", + "statistical_area_units_2017": [ + "Bledisloe Park", + "Buckland", + "Buckland South", + "Eden Road-Hill Top", + "Opuawhanga", + "Paerata-Cape Hill", + "Pukekohe North", + "Pukekohe West", + "Redoubt", + "Tuakau" + ] + }, + { + "heading": "Queenstown", + "statistical_area_units_2017": [ + "Arrowtown", + "Arthurs Point", + "Frankton", + "Kelvin Heights", + "Queenstown Bay", + "Queenstown Hill", + "Queenstown urban area", + "Sunshine Bay", + "Wanaka" + ] + }, + { + "heading": "Southern Auckland Zone", + "statistical_area_units_2017": [ + "Ambury", + "Aorere", + "Arahanga", + "Ardmore", + "Baverstock Oaks", + "Beachlands-Maraetai", + "Beaumont", + "Bleakhouse", + "Bremner", + "Bucklands and Eastern Beaches", + "Bucklands Beach South", + "Burbank", + "Burswood", + "Clendon North", + "Clendon South", + "Clover Park", + "Cockle Bay", + "Dannemora", + "Dingwall", + "Donegal Park", + "Drury", + "Edgewater", + "Elsmore Park", + "Favona North", + "Favona South", + "Favona West", + "Ferguson", + "Golfland", + "Grange", + "Greenmount", + "Half Moon Bay", + "Harania East", + "Harania North", + "Harania West", + "Highbrook", + "Highland Park", + "Hillpark", + "Hingaia", + "Homai East", + "Homai West", + "Howick Central", + "Howick South", + "Howick West", + "Hyperion", + "Kilkenny", + "Kohuora", + "Leabank", + "Mangere Bridge", + "Mangere Central", + "Mangere East", + "Mangere South", + "Mangere Station", + "Manukau Central", + "Manurewa Central", + "Manurewa East", + "Mascot", + "Massey Park", + "Meadowland", + "Mellons Bay", + "Middlemore", + "Mill Road", + "Millhouse", + "Mission Heights", + "Murvale", + "Opaheke", + "Ormiston", + "Otara East", + "Otara North", + "Otara South", + "Otara West", + "Pahurehure", + "Pakuranga Central", + "Pakuranga East", + "Pakuranga North", + "Papakura Central", + "Papakura East", + "Papakura North", + "Papakura North East", + "Papakura South", + "Papatoetoe Central", + "Papatoetoe East", + "Papatoetoe North", + "Papatoetoe West", + "Pigeon Mountain North", + "Pigeon Mountain South", + "Point View", + "Puhinui North", + "Puhinui South", + "Randwick Park", + "Red Hill", + "Redoubt East", + "Redoubt North", + "Redoubt South", + "Rongomai", + "Rosehill", + "Rowandale", + "Runciman", + "Shelly Park", + "Sunnyhills", + "Takanini North", + "Takanini South", + "Takanini West", + "Totara Heights", + "Turanga", + "Viscount", + "Wattle Farm", + "Weymouth East", + "Weymouth West", + "Whangapouri Creek", + "Wiri", + "Wymondley" + ] + }, + { + "heading": "Tauranga", + "statistical_area_units_2017": [ + "Arataki", + "Bellevue", + "Bethlehem", + "Bethlehem East", + "Brookfield", + "Doncaster", + "Gate Pa", + "Gravatt", + "Greerton", + "Hairini", + "Judea", + "Kairua", + "Kaitemako", + "Matapihi", + "Matua", + "Maungatapu", + "Motuopae Island", + "Mt Maunganui North", + "Ohauiti-Ngapeke", + "Omanu", + "Omokoroa", + "Otumoetai North", + "Otumoetai South", + "Pacific View", + "Palm Beach", + "Palm Springs", + "Papamoa Beach East", + "Poike", + "Pyes Pa", + "Sulphur Point", + "Tauranga Central", + "Tauranga Hospital", + "Tauranga South", + "Te Maunga", + "Te Puna", + "Te Reti", + "Welcome Bay East", + "Welcome Bay West", + "Yatton Park" + ] + }, + { + "heading": "Waiuku", + "statistical_area_units_2017": [ + "Pukeoware", + "South Waiuku", + "Waiuku East", + "Waiuku West" + ] + }, + { + "heading": "Western Auckland Zone", + "statistical_area_units_2017": [ + "Armour Bay", + "Birdwood East", + "Birdwood West", + "Crum Park", + "Durham Green", + "Edmonton", + "Fairdene", + "Fruitvale", + "Glen Eden East", + "Glendene North", + "Glendene South", + "Green Bay", + "Henderson North", + "Henderson South", + "Henderson West", + "Herald", + "Hobsonville East", + "Hobsonville South", + "Huapai", + "Kaurilands", + "Kelston Central", + "Kingdale", + "Konini", + "Kumeu East", + "Kumeu West", + "Laingholm", + "Lucken Point", + "Lynnmall", + "Massey West", + "Matipo", + "McLaren Park", + "Mcleod", + "New Lynn North", + "New Lynn South", + "Opanuku", + "Oratia", + "Oratia West", + "Otimai", + "Palm Heights", + "Parrs Park", + "Parrs Park West", + "Ranui Domain", + "Ranui South", + "Rewarewa", + "Riverhead Urban", + "Royal Heights", + "Royal Road West", + "Starling Park", + "Sturges North", + "Sunnyvale", + "Swanson", + "Swanson South", + "Tangutu", + "Taupaki", + "Te Atatu Central", + "Titirangi South", + "Urlich", + "Waima", + "Waimauku", + "Waimumu North", + "Waimumu South", + "Waipareira West", + "Waitakere", + "Wakeling", + "West Harbour", + "Westgate", + "Whenuapai West", + "Woodglen" + ] + }, + { + "heading": "Other locations", + "statistical_area_units_2017": [ + "Algies Bay", + "Bombay", + "Clevedon", + "Glenbrook", + "Helensville", + "Helensville South", + "Hunua", + "Karekare", + "Kaukapakapa", + "Kaukapakapa Rural", + "Kingseat", + "Mahurangi", + "Muriwai Beach", + "Muriwai Valley", + "Parakai Urban", + "Patumahoe", + "Pokeno", + "Rewiti", + "Riverhead", + "Snells Beach", + "Tahekeroa", + "Waiheke Island", + "Waitakere West", + "Warkworth" + ] + } + ] + }, + "2": { + "locations": [ + { + "heading": "Ashburton", + "statistical_area_units_2017": [ + "Allenton East", + "Allenton West", + "Ashburton Central East", + "Ashburton Central West", + "Ashburton East", + "Ashburton North", + "Fairton", + "Hampstead", + "Netherby", + "Plains Railway", + "Tinwald" + ] + }, + { + "heading": "Blenheim", + "statistical_area_units_2017": [ + "Blenheim Central", + "Mayfield", + "Omaka", + "Redwoodtown", + "Renwick", + "Riverlands", + "Spring Creek-Grovetown", + "Springlands", + "Whitney", + "Witherlea", + "Woodbourne" + ] + }, + { + "heading": "Cambridge Zone", + "statistical_area_units_2017": [ + "Cambridge Central", + "Cambridge North", + "Cambridge West", + "Hautapu", + "Leamington East", + "Leamington West", + "Pukerimu", + "Swayne" + ] + }, + { + "heading": "Christchurch", + "statistical_area_units_2017": [ + "Addington", + "Aidanfield", + "Aorangi", + "Aranui", + "Avon Loop", + "Avondale", + "Avonhead", + "Avonhead West", + "Avonside", + "Barrington North", + "Barrington South", + "Beckenham", + "Belfast", + "Belfast South", + "Bexley", + "Bishopdale", + "Bishopdale North", + "Bromley", + "Broomfield", + "Bryndwr", + "Burnside", + "Burwood", + "Camside", + "Casebrook", + "Cashmere East", + "Cashmere West", + "Cathedral Square", + "Clarkville", + "Courtenay", + "Dallington", + "Deans Bush", + "Diamond Harbour", + "Edgeware", + "Ensors", + "Fendalton", + "Ferrymead", + "Governors Bay", + "Hagley Park", + "Halswell Domain", + "Halswell South", + "Halswell West", + "Harewood", + "Hawthornden", + "Heathcote Valley", + "Hendersons Basin", + "Highfield Park", + "Hillmorton", + "Holmwood", + "Hoon Hay", + "Hoon Hay South", + "Hornby North", + "Hornby South", + "Ilam", + "Islington", + "Jellie Park", + "Kaiapoi East", + "Kaiapoi North East", + "Kaiapoi North West", + "Kaiapoi South", + "Kaiapoi West", + "Kennedys Bush", + "Linwood", + "Linwood East", + "Linwood North", + "Lyttelton", + "Mairehau", + "Mairehau North", + "Mansfield", + "Mcleans Island", + "Merivale", + "Merrin", + "Middleton", + "Mona Vale", + "Moncks Bay", + "Mt Pleasant", + "New Brighton", + "North Beach", + "Northcote", + "Oaklands East", + "Oaklands West", + "Opawa", + "Papanui", + "Paparua", + "Parklands", + "Phillipstown", + "Pines-Kairaki Beach", + "Prebbleton", + "Prestons", + "Quail Island", + "Rapaki Track", + "Rawhiti", + "Redwood North", + "Redwood South", + "Riccarton", + "Riccarton South", + "Riccarton West", + "Richmond North", + "Richmond South", + "Russley", + "Rutland", + "Sawyers Arms", + "Shirley East", + "Shirley West", + "Silverstream", + "Sockburn", + "Somerfield", + "South Brighton", + "Spreydon", + "St Albans East", + "St Albans West", + "St Martins", + "Strowan", + "Styx", + "Styx Mill", + "Sumner", + "Sydenham", + "Templeton", + "Travis", + "Travis Wetland", + "Trents-Ladbrooks", + "Upper Riccarton", + "Waimairi Beach", + "Wainoni", + "Wairarapa", + "Waltham", + "Westburn", + "Westhaven", + "Westmorland", + "Wharenui", + "Wigram", + "Woolston South", + "Woolston West", + "Yaldhurst" + ] + }, + { + "heading": "Hamilton Zone", + "statistical_area_units_2017": [ + "Bader", + "Beerescourt", + "Bryant", + "Brymer", + "Burbush", + "Chartwell", + "Chedworth", + "Clarkin", + "Claudelands", + "Crawshaw", + "Dinsdale North", + "Dinsdale South", + "Enderley", + "Eureka", + "Fairview Downs", + "Flagstaff", + "Frankton Junction", + "Glenview", + "Gordonton", + "Grandview", + "Hamilton Central", + "Hamilton East", + "Hamilton Lake", + "Hillcrest West", + "Horotiu", + "Horsham Downs", + "Huntington", + "Insoll", + "Kainui", + "Kaipaki", + "Lake Cameron", + "Maeroa", + "Matangi", + "Melville", + "Nawton", + "Naylor", + "Newstead", + "Ngahinapouri", + "Ngaruawahia", + "Ohaupo", + "Peachgrove", + "Peacocke", + "Porritt", + "Pukete", + "Pukete West", + "Queenwood", + "Riverlea", + "Rotokauri", + "Rototuna", + "Silverdale", + "Swarbrick", + "Sylvester", + "Tamahere-Tauwhare", + "Taupiri Community", + "Te Kowhai", + "Te Rapa", + "Te Rapa North", + "Temple View", + "University", + "Whatawhata" + ] + }, + { + "heading": "Hastings Zone", + "statistical_area_units_2017": [ + "Akina", + "Anderson Park", + "Bridge Pa", + "Brookvale", + "Camberley", + "Clive", + "Flaxmere East", + "Frimley", + "Hastings Central", + "Haumoana", + "Havelock Hills", + "Havelock North Central", + "Iona", + "Irongate", + "Karamu", + "Kingsley-Chatham", + "Lochain", + "Longlands South", + "Mahora", + "Mayfair", + "Omahu", + "Pakipaki", + "Pakowhai", + "Parkvale", + "Raureka", + "St Leonards", + "Te Mata", + "Te Mata Hills", + "Twyford", + "Whakatu", + "Woolwich" + ] + }, + { + "heading": "Kapiti", + "statistical_area_units_2017": [ + "Otaihanga", + "Paekakariki", + "Paraparaumu Beach North", + "Paraparaumu Beach South", + "Paraparaumu Central", + "Peka Peka", + "Raumati Beach", + "Raumati South", + "Waikanae Beach", + "Waikanae East", + "Waikanae Park", + "Waikanae West" + ] + }, + { + "heading": "Lower Hutt Zone", + "statistical_area_units_2017": [ + "Alicetown", + "Arakura", + "Avalon East", + "Avalon West", + "Belmont", + "Boulcott", + "Delaney", + "Eastbourne", + "Epuni East", + "Epuni West", + "Esplanade", + "Fernlea", + "Glendale", + "Gracefield", + "Haywards-Manor Park", + "Holborn", + "Homedale East", + "Homedale West", + "Hutt Central", + "Kelson", + "Korokoro", + "Manuka", + "Maungaraki", + "Melling", + "Moera", + "Naenae North", + "Naenae South", + "Normandale", + "Parkway", + "Petone Central", + "Taita North", + "Taita South", + "Tawhai", + "Tirohanga", + "Waiwhetu North", + "Waiwhetu South", + "Waterloo East", + "Waterloo West", + "Wilford", + "Woburn North", + "Woburn South" + ] + }, + { + "heading": "Matamata", + "statistical_area_units_2017": [ + "Matamata North", + "Matamata South" + ] + }, + { + "heading": "Motueka", + "statistical_area_units_2017": [ + "Jackett Island", + "Motueka East", + "Motueka West" + ] + }, + { + "heading": "Napier Zone", + "statistical_area_units_2017": [ + "Ahuriri", + "Awatoto", + "Bay View", + "Bluff Hill", + "Eskdale", + "Greenmeadows", + "Hospital Hill", + "Maraenui", + "Marewa", + "Mclean Park", + "Meeanee", + "Nelson Park", + "Onekawa Central", + "Onekawa South", + "Onekawa West", + "Pirimai", + "Poraiti", + "Tamatea North", + "Tamatea South", + "Taradale North", + "Taradale South", + "Waiohiki", + "Westshore" + ] + }, + { + "heading": "Nelson", + "statistical_area_units_2017": [ + "Aniseed Hill", + "Atawhai", + "Atmore", + "Bell Island", + "Best Island", + "Britannia", + "Broads", + "Bronte", + "Clifton", + "Enner Glynn", + "Glenduan", + "Grampians", + "Hope", + "Isel Park", + "Kirks", + "Langbein", + "Maitai", + "Maitlands", + "Nayland", + "Nelson Airport", + "Ngawhatu", + "Port Nelson", + "Ranzau", + "Richmond East", + "Richmond West", + "Saxton", + "Tahuna Hills", + "Tahunanui", + "The Brook", + "The Wood", + "Toi Toi", + "Trafalgar", + "Washington" + ] + }, + { + "heading": "New Plymouth", + "statistical_area_units_2017": [ + "Barrett", + "Bell Block", + "Bowden", + "Carrington", + "Fernleigh", + "Fitzroy", + "Frankleigh", + "Glen Avon", + "Highlands Park", + "Kawaroa", + "Lynmouth", + "Mangaoraka", + "Marfell", + "Marsland Hill", + "Merrilands", + "Moturoa", + "Mount Bryan", + "New Plymouth Central", + "Oakura", + "Omata", + "Paraite", + "Spotswood", + "Struan Park", + "Upper Westown", + "Welbourn", + "Westown" + ] + }, + { + "heading": "Porirua Zone", + "statistical_area_units_2017": [ + "Adventure", + "Ascot Park", + "Cannons Creek East", + "Cannons Creek North", + "Cannons Creek South", + "Discovery", + "Elsdon-Takapuwahia", + "Endeavour", + "Mana-Camborne", + "Onepoto", + "Papakowhai North", + "Papakowhai South", + "Paremata-Postgate", + "Pauatahanui", + "Plimmerton", + "Porirua Central", + "Porirua East", + "Pukerua Bay", + "Ranui Heights", + "Resolution", + "Titahi Bay North", + "Titahi Bay South", + "Waitangirua" + ] + }, + { + "heading": "Rangiora", + "statistical_area_units_2017": [ + "Kingsbury", + "Lehmans", + "Rangiora Central", + "Rangiora East", + "Rangiora North", + "Rangiora West", + "Southbrook" + ] + }, + { + "heading": "Rolleston", + "statistical_area_units_2017": [ + "Rolleston Central", + "Rolleston North East", + "Rolleston North West", + "Rolleston South East", + "Rolleston South West" + ] + }, + { + "heading": "Taupo", + "statistical_area_units_2017": [ + "Acacia Bay", + "Hilltop", + "Lakewood", + "Maunganamu", + "Nukuhau", + "Rangatira", + "Rangatira Park", + "Richmond Heights", + "Tauhara", + "Taupo Central", + "Taupo East", + "Waipahihi", + "Wairakei-Aratiatia", + "Wharewaka" + ] + }, + { + "heading": "Te Awamutu Zone", + "statistical_area_units_2017": [ + "Allen Road", + "Kihikihi", + "Kihikihi Flat", + "Lake Ngaroto", + "Te Awamutu Central", + "Te Awamutu East", + "Te Awamutu South", + "Te Awamutu West", + "Te Rahu", + "Tokanui" + ] + }, + { + "heading": "Te Puke Community", + "statistical_area_units_2017": [ + "Te Puke East", + "Te Puke West" + ] + }, + { + "heading": "Upper Hutt Zone", + "statistical_area_units_2017": [ + "Akatarawa", + "Brentwood", + "Clouston Park", + "Ebdentown", + "Elderslea", + "Emerald Hill", + "Heretaunga", + "Heretaunga-Silverstream", + "Maidstone", + "Maoribank", + "Pinehaven", + "Poets Block", + "Riverstone Terraces", + "Te Marua", + "Totara Park", + "Trentham North", + "Trentham South", + "Upper Hutt Central", + "Wallaceville" + ] + }, + { + "heading": "Wellington Zone", + "statistical_area_units_2017": [ + "Adelaide", + "Aro Street-Nairn Street", + "Awarua", + "Berhampore East", + "Berhampore West", + "Brooklyn", + "Brooklyn South", + "Churton Park North", + "Churton Park South", + "Crofton Downs", + "Glenside North", + "Greenacres", + "Grenada North", + "Grenada Village", + "Happy Valley-Owhiro Bay", + "Hataitai North", + "Horokiwi", + "Island Bay East", + "Island Bay West", + "Johnsonville Central", + "Johnsonville East", + "Johnsonville North", + "Kaiwharawhara", + "Karaka Bay-Worser Bay", + "Karori East", + "Karori North", + "Karori Park", + "Karori South", + "Kelburn", + "Khandallah Park-Broadmeadows", + "Kilbirnie East", + "Kilbirnie West-Hataitai South", + "Kingston-Mornington", + "Lambton", + "Linden", + "Lyall Bay-Airport-Moa Point", + "Maupuia", + "Melrose-Houghton Bay-Southgate", + "Miramar", + "Miramar North", + "Miramar South", + "Miramar West", + "Mitchelltown", + "Mt Cook-Wallace Street", + "Mt Victoria West", + "Newlands East", + "Newlands North", + "Newlands South", + "Newtown East", + "Newtown West", + "Ngaio South", + "Ngauranga East", + "Ngauranga West", + "Northland", + "Northland North", + "Oriental Bay", + "Paparangi", + "Paparangi West", + "Rangoon Heights", + "Raroa", + "Roseneath", + "Seatoun", + "Seatoun Tunnel West", + "Strathmore Park", + "Taitville", + "Takapu", + "Tawa Central", + "Tawa South", + "Te Kainga", + "Thorndon-Tinakori Road", + "Vogeltown", + "Vogeltown West", + "Wadestown", + "Willis Street-Cambridge Terrace", + "Wilton", + "Woodridge" + ] + }, + { + "heading": "Whangarei", + "statistical_area_units_2017": [ + "Abbey Caves", + "Hikurangi", + "Horahora", + "Kamo East", + "Kamo West", + "Kensington", + "Mairtown", + "Maunu", + "Morningside", + "Onerahi", + "Otaika-Portland", + "Otangarei", + "Parahaki", + "Port-Limeburners", + "Raumanga East", + "Raumanga West", + "Regent", + "Riverside", + "Sherwood Rise", + "Springs Flat", + "Te Hihi", + "Three Mile Bush", + "Tikipunga East", + "Tikipunga West", + "Vinetown", + "Western Hills", + "Whangarei Central", + "Whau Valley", + "Woodhill" + ] + }, + { + "heading": "Woodend", + "statistical_area_units_2017": [ + "Ravenswood", + "Woodend", + "Woodend West" + ] + }, + { + "heading": "Other locations", + "statistical_area_units_2017": [ + "Amberley", + "Awhitu", + "Brightwater", + "Cape Rodney", + "Cape Rodney South", + "Cromwell", + "Darfield", + "Great Barrier Island", + "Islands-Motutapu Rangitoto Rakino", + "Kaiaua", + "Katikati Community", + "Kawakawa-Orere", + "Kawau", + "Kerikeri", + "Leeston", + "Leigh", + "Lincoln", + "Little Barrier Island", + "Makara-Ohariu", + "Mangatawhiri", + "Mangawhai", + "Mangawhai Heads", + "Mapua", + "Maramarua", + "Matakana", + "Matheson Bay", + "Meremere", + "Moonshine Valley", + "Ngunguru", + "Omaha", + "Onewhero", + "Otaua", + "Oxford", + "Paparata", + "Parakai Rural", + "Point Wells", + "Raglan", + "Rakaia", + "Rapaura", + "South Head", + "Tauhoa-Puhoi", + "Te Kauwhata", + "Waihi Beach", + "Wakefield", + "Wellsford", + "Whangamata", + "Whitianga" + ] + } + ] + }, + "3": { + "locations": [ + { + "heading": "Dunedin", + "statistical_area_units_2017": [ + "Abbotsford", + "Andersons Bay", + "Aramoana", + "Balmacewen", + "Belleknowes", + "Brighton", + "Broad Bay-Portobello", + "Brockville", + "Bush Road", + "Caledonian", + "Caversham", + "Company Bay", + "Concord", + "Corstorphine East", + "Corstorphine West", + "East Taieri", + "Fairfield", + "Fernhill", + "Forbury", + "Forrester Park", + "Glenleith", + "Green Island", + "Halfway Bush", + "Harbourside", + "Helensburgh", + "High St-Stuart St", + "Inner Peninsula", + "Kaikorai Hill", + "Kenmure", + "Macandrew Bay", + "Maori Hill", + "Mornington", + "Mosgiel East", + "Mosgiel South", + "Musselburgh", + "North Dunedin", + "North East Valley", + "Opoho", + "Otago University", + "Pine Hill", + "Port Chalmers", + "Ravensbourne", + "Roslyn North", + "Roslyn South", + "Saddle Hill", + "Sandymount", + "Sawyers Bay", + "South Dunedin", + "St Clair", + "St Kilda Central", + "St Kilda East", + "St Kilda West", + "St Leonards-Blanket Bay", + "Stuart St-Frederick St", + "Taiaroa-Cape Saunders", + "Vauxhall", + "Wakari", + "Waldronville", + "Wingatui", + "Woodhaugh", + "Wyllies Crossing" + ] + }, + { + "heading": "Feilding", + "statistical_area_units_2017": [ + "Feilding Central", + "Feilding East", + "Feilding North", + "Feilding West", + "Maewa", + "Oroua Bridge", + "Rakiraki" + ] + }, + { + "heading": "Gisborne", + "statistical_area_units_2017": [ + "Gisborne Airport", + "Gisborne Central", + "Kaiti North", + "Kaiti South", + "Makaraka", + "Mangapapa", + "Matokitoki", + "Outer Kaiti", + "Riverdale", + "Tamarau", + "Te Hapara", + "Wainui", + "Whataupoko" + ] + }, + { + "heading": "Greymouth", + "statistical_area_units_2017": [ + "Blaketown", + "Cobden", + "Greymouth Central", + "Greymouth South", + "Kaiata", + "Karoro", + "Runanga-Rapahoe", + "South Beach-Camerons" + ] + }, + { + "heading": "Hawera", + "statistical_area_units_2017": [ + "Hawera North", + "Hawera South", + "Hawera West", + "Normanby", + "Ohawe Beach", + "Tawhiti", + "Waingongoro" + ] + }, + { + "heading": "Hokitika", + "statistical_area_units_2017": [ + "Hokitika Urban", + "Kaniere" + ] + }, + { + "heading": "Huntly", + "statistical_area_units_2017": [ + "Huntly East", + "Huntly West" + ] + }, + { + "heading": "Kaitaia", + "statistical_area_units_2017": [ + "Kaitaia East", + "Kaitaia West" + ] + }, + { + "heading": "Levin", + "statistical_area_units_2017": [ + "Kohitere", + "Lake Horowhenua", + "Levin East", + "Levin North", + "Levin South", + "Levin West", + "Playford Park", + "Waiopehu", + "Waitarere" + ] + }, + { + "heading": "Masterton", + "statistical_area_units_2017": [ + "Homebush-Te Ore Ore", + "Lansdowne", + "Masterton Central", + "Masterton East", + "Masterton Railway", + "Masterton West", + "Ngaumutawa", + "Opaki-Fernridge", + "Solway North", + "Solway South" + ] + }, + { + "heading": "Morrinsville", + "statistical_area_units_2017": [ + "Morrinsville East", + "Morrinsville West" + ] + }, + { + "heading": "Oamaru", + "statistical_area_units_2017": [ + "Ardgowan", + "Cape Wanbrow", + "Oamaru Central", + "Oamaru North", + "Oamaru South", + "Orana Park", + "Pukeuri", + "Weston" + ] + }, + { + "heading": "Palmerston North", + "statistical_area_units_2017": [ + "Aokautere", + "Ashhurst", + "Awapuni North", + "Awapuni South", + "Awapuni West", + "Cloverlea", + "Highbury", + "Hokowhitu East", + "Hokowhitu Lagoon", + "Hokowhitu West", + "Kairanga", + "Kelvin Grove", + "Linton Military Camp", + "Longburn", + "Massey University", + "Milson", + "Palmerston North Central", + "Palmerston North Hospital", + "Papaeoia", + "Roslyn", + "Stoney Creek", + "Takaro", + "Terrace End", + "West End", + "Westbrook", + "Whakarongo" + ] + }, + { + "heading": "Picton", + "statistical_area_units_2017": [ + "Picton", + "Waikawa" + ] + }, + { + "heading": "Rotorua", + "statistical_area_units_2017": [ + "Fairy Springs", + "Fenton", + "Fordlands", + "Glenholme East", + "Glenholme West", + "Hillcrest", + "Kawaha Point", + "Koutu", + "Kuirau", + "Lynmore", + "Mangakakahi", + "Ngapuna", + "Ngongotaha North", + "Ngongotaha South", + "Ohinemutu", + "Owhata East", + "Owhata South", + "Owhata West", + "Poets Corner", + "Pomare", + "Pukehangi North", + "Pukehangi South", + "Selwyn Heights", + "Springfield", + "Sunnybrook", + "Utuhina", + "Victoria", + "Western Heights", + "Whakarewarewa" + ] + }, + { + "heading": "Thames", + "statistical_area_units_2017": [ + "Moanataiari", + "Parawai" + ] + }, + { + "heading": "Timaru", + "statistical_area_units_2017": [ + "Fairview-Scarborough", + "Fraser Park", + "Gleniti", + "Glenwood", + "Highfield", + "Maori Park", + "Marchwiel", + "Otipua Creek-Washdyke Flat", + "Parkside", + "Redruth", + "Seaview", + "Timaru Gardens", + "Waimataitai", + "Washdyke", + "Watlington" + ] + }, + { + "heading": "Waitara", + "statistical_area_units_2017": [ + "Waitara East", + "Waitara West" + ] + }, + { + "heading": "Whakatane", + "statistical_area_units_2017": [ + "Allandale-Mokorua", + "Coastlands", + "Maraetotara", + "Ohope", + "Orini", + "Poroporo", + "Trident", + "Whakatane North", + "Whakatane West" + ] + }, + { + "heading": "Other locations", + "statistical_area_units_2017": [ + "Alexandra", + "Carterton", + "Cloustonville", + "Coromandel", + "Dargaville", + "Edgecumbe", + "Geraldine", + "Greytown", + "Hanmer Springs", + "Inglewood", + "Kaikohe", + "Kaikoura Township", + "Kapiti Island", + "Kauwhata", + "Kawakawa", + "Mana Island", + "Mangaroa", + "Martinborough", + "Maungakotukutuku", + "Methven", + "Ngatea", + "Opiki", + "Otaki", + "Otorohanga", + "Paekakariki Hill", + "Paeroa", + "Paihia", + "Pencarrow", + "Pleasant Point", + "Russell", + "Shannon", + "Taipa Bay-Mangonui", + "Tairua", + "Takaka", + "Te Aroha", + "Temuka", + "Tokomaru", + "Tokorangi-Hiwinui", + "Twizel Community", + "Waihi", + "Waikouaiti", + "Waipawa", + "Winton" + ] + } + ] + }, + "4": { + "residual": "Area 4 is any location that is not in Area 1, Area 2 or Area 3." + } + } + }, + "rows": [], + "row_shape": { + "ta_code": "Stats NZ territorial-authority code at ta_vintage", + "ta_name": "territorial-authority name", + "shares": "list of {as_area: 1-4, population_share: number in [0, 1]}; shares sum to 1 within share_tolerance", + "population_source": "the pinned population source and its period" + } +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/as_rate_bridge.json b/packages/microcosm-build/src/microcosm/build/nz/as_rate_bridge.json new file mode 100644 index 0000000000..4cd6f0ada2 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/as_rate_bridge.json @@ -0,0 +1,442 @@ +{ + "version": 1, + "country": "nz", + "method_card_row": "MC11", + "description": "Host bridge for the two law-derived Accommodation Supplement inputs that rulespec-nz takes as inputs \"for later cross-module wiring\": the Social Security Regulations 2018 reg 17 base rate and the reg 18 non-beneficiary income cutout. Every rate comes from an engine evaluation of a bound rulespec-nz module; this resource declares only which outputs combine, with which coefficients, under which input overrides. It ports the statutory branch (treasury_host_aligned false) of the axiom-oracles IncomeExplorer conformance harness. It is the one sanctioned exception to \"real Axiom runtime only\" (method card amendment MC11a) and needs Max's MC11 ruling. When rulespec-nz encodes regs 17 and 18 (6 Oct plan B13) this resource and the bridge graph nodes are deleted; only the Accommodation Supplement branches re-run.", + "status": "awaiting D1 (MC11, MC11a)", + "source": { + "repository": "TheAxiomFoundation/ops", + "path": "nz-lane/emtr_reproduction/run.py", + "commit": "ba6e7d6e4f488fdb2762dfb9d91c90f3238b719c", + "branch": "main", + "lines": "2470-2637", + "supporting_lines": { + "jobseeker_payment": "1931-1960", + "zero_income_jobseeker_components": "2017-2024", + "compiled_parameters": "1525-1527, 1552-1561", + "weeks_constants": "166-168" + }, + "branch_of_harness": "statutory (treasury_host_aligned false)", + "plan_citation": "The plan cites lines 1695-1790 of the same file at 302e43bafe5268448da3f3e16047832c1a3b736c on branch launch/ga-path-execution-gate, which is not on ops main. The composition is the same; main aggregates the per-adult zero-income amounts through aggregate_family where the branch sums them directly." + }, + "rate_bindings": [ + "main_benefits_rates", + "family_tax_credits" + ], + "outputs": [ + { + "id": "reg17_base_rate", + "target_input": "accommodation_supplement_base_rate_weekly_amount", + "legal_basis": "Social Security Regulations 2018 reg 17", + "graph_node": "nz.as.bridge.base_rate", + "method": "counterfactual_sum", + "terms": [ + { + "binding": "main_benefits_rates", + "variable": "jobseeker_support_net_weekly_payment", + "engine_entity": "Person", + "coefficient": 1, + "multiplier": "adults_in_couple", + "multiplier_rule": "1 for a single unit, 2 for a partnered unit: the harness repeats the per-person zero-income amount for each partner (run.py:2017-2024) and aggregates the components to the family (run.py:2545-2554)", + "input_overrides": [ + { + "variable": "jobseeker_support_applicant_age", + "value": 25, + "value_source": "harness_constant", + "reason": "harness constant for IncomeExplorer stylized adults (run.py:1941); open item: whether reg 17 uses each adult's own age-dependent rate is unresolved (reg 17 text not read in this lane)" + }, + { + "variable": "jobseeker_support_benefit_commenced_on_or_after_1998_07_01", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1942)" + }, + { + "variable": "jobseeker_support_dependent_children_count", + "unit_attribute": "family.n_dependent_children", + "value_source": "unit", + "reason": "harness: family_shape dependent_child_count (run.py:1937,1943)" + }, + { + "variable": "jobseeker_support_living_with_parent", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1944)" + }, + { + "variable": "jobseeker_support_partner_ineligible_due_to_sanction_or_strike", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1945)" + }, + { + "variable": "jobseeker_support_partner_receives_main_benefit", + "unit_attribute": "family.is_partnered", + "value_source": "unit", + "reason": "harness: family_shape partner_present (run.py:1946)" + }, + { + "variable": "jobseeker_support_partner_receives_new_zealand_superannuation_or_veterans_pension", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1947); superannuitant units follow superannuitant_units below" + }, + { + "variable": "jobseeker_support_single", + "unit_attribute": "family.is_single", + "value_source": "unit", + "reason": "harness: not partner_present (run.py:1948)" + }, + { + "variable": "jobseeker_support_transferred_2013_no_dependent_children", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1950)" + }, + { + "variable": "jobseeker_support_youngest_dependent_child_age", + "unit_attribute": "family.youngest_dependent_child_age", + "value_source": "unit", + "reason": "harness: family_shape youngest_child_age (run.py:1938,1951)" + }, + { + "variable": "jobseeker_support_total_income", + "value": 0, + "value_source": "bridge_point", + "reason": "zero-income Jobseeker Support (run.py:2021)" + } + ] + }, + { + "binding": "family_tax_credits", + "variable": "family_tax_credit_prescribed_amount", + "engine_entity": "Family", + "coefficient": 1, + "divisor": 52, + "applies_when": "family.n_dependent_children >= 1", + "divisor_rule": "reg 17 uses the annual eldest-child family tax credit divided by 52 (run.py:2556-2562); the harness's 365/7 divisor is a Treasury-host diagnostic only and is not used", + "input_overrides": [ + { + "variable": "family_tax_credit_eldest_dependent_child_care_units", + "value": 1, + "value_source": "bridge_point", + "reason": "one eldest-child care unit, so the prescribed amount equals family_tax_credit_eldest_child_annual_amount (the parameter the harness reads, run.py:1525-1527)" + }, + { + "variable": "family_tax_credit_subsequent_dependent_child_care_units", + "value": 0, + "value_source": "bridge_point", + "reason": "no subsequent-child amount enters reg 17" + } + ] + } + ] + }, + { + "id": "reg18_cutout", + "target_input": "accommodation_supplement_non_beneficiary_income_cutout_weekly_amount", + "legal_basis": "Social Security Regulations 2018 reg 18", + "graph_node": "nz.as.bridge.cutout", + "method": "branched", + "branches": [ + { + "id": "sole_parent", + "applies_when": "family.is_sole_parent", + "harness_lines": "2491-2500", + "harness_expression": "main_benefit_income_test_lower_weekly_threshold + jobseeker_support_single_with_dependent_children_weekly_rate / main_benefit_income_test_3_abatement_rate", + "why_not_solve_zero": "The engine applies Income Test 1 (two-tier) to a single person with dependent children (rates.yaml jobseeker_support_weekly_income_test_reduction), while the harness pairs the single-with-children rate with the Income Test 3 slope. No input combination makes the engine evaluate that pair, so a zero-solve on the unit's own Jobseeker schedule would not reproduce golden-08. The three engine evaluations below recover the same three compiled quantities from derived outputs, with no parameter accessor.", + "evaluations": [ + { + "id": "gross_rate", + "binding": "main_benefits_rates", + "variable": "jobseeker_support_gross_weekly_rate", + "engine_entity": "Person", + "input_overrides": [ + { + "variable": "jobseeker_support_single", + "value": true, + "value_source": "bridge_point", + "reason": "single applicant" + }, + { + "variable": "jobseeker_support_dependent_children_count", + "value": 1, + "value_source": "bridge_point", + "reason": "selects the with-dependent-children rate branch" + }, + { + "variable": "jobseeker_support_youngest_dependent_child_age", + "value": 0, + "value_source": "bridge_point", + "reason": "below the 14-year threshold, so the gross rate is jobseeker_support_single_with_dependent_children_weekly_rate" + }, + { + "variable": "jobseeker_support_total_income", + "value": 0, + "value_source": "bridge_point", + "reason": "the gross rate does not depend on income" + } + ] + }, + { + "id": "reduction_at_anchor", + "binding": "main_benefits_rates", + "variable": "jobseeker_support_weekly_income_test_reduction", + "engine_entity": "Person", + "input_overrides": [ + { + "variable": "jobseeker_support_single", + "value": true, + "value_source": "bridge_point", + "reason": "single applicant" + }, + { + "variable": "jobseeker_support_dependent_children_count", + "value": 0, + "value_source": "bridge_point", + "reason": "with transferred_2013 false this selects Income Test 3" + }, + { + "variable": "jobseeker_support_transferred_2013_no_dependent_children", + "value": false, + "value_source": "bridge_point", + "reason": "Income Test 3 branch" + }, + { + "variable": "jobseeker_support_total_income", + "value": 500, + "value_source": "bridge_point", + "reason": "the harness anchor (run.py:2502); must exceed the lower threshold, else the bridge refuses" + } + ] + }, + { + "id": "reduction_after_step", + "binding": "main_benefits_rates", + "variable": "jobseeker_support_weekly_income_test_reduction", + "engine_entity": "Person", + "input_overrides": [ + { + "variable": "jobseeker_support_single", + "value": true, + "value_source": "bridge_point", + "reason": "single applicant" + }, + { + "variable": "jobseeker_support_dependent_children_count", + "value": 0, + "value_source": "bridge_point", + "reason": "Income Test 3 branch" + }, + { + "variable": "jobseeker_support_transferred_2013_no_dependent_children", + "value": false, + "value_source": "bridge_point", + "reason": "Income Test 3 branch" + }, + { + "variable": "jobseeker_support_total_income", + "value": 501, + "value_source": "bridge_point", + "reason": "anchor plus the harness's one-dollar step (run.py:2504)" + } + ] + } + ], + "composition": { + "slope": "reduction_after_step - reduction_at_anchor (per dollar of weekly income)", + "lower_threshold": "500 - reduction_at_anchor / slope", + "cutout": "lower_threshold + gross_rate / slope", + "refuse_when": "reduction_at_anchor <= 0 or slope <= 0" + } + }, + { + "id": "all_other_units", + "applies_when": "not family.is_sole_parent", + "harness_lines": "2501-2511", + "method": "anchor_extrapolation", + "evaluations": [ + { + "id": "payment_at_anchor", + "binding": "main_benefits_rates", + "variable": "jobseeker_support_net_weekly_payment", + "engine_entity": "Person", + "input_overrides": [ + { + "variable": "jobseeker_support_applicant_age", + "value": 25, + "value_source": "harness_constant", + "reason": "harness constant for IncomeExplorer stylized adults (run.py:1941); open item: whether reg 17 uses each adult's own age-dependent rate is unresolved (reg 17 text not read in this lane)" + }, + { + "variable": "jobseeker_support_benefit_commenced_on_or_after_1998_07_01", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1942)" + }, + { + "variable": "jobseeker_support_dependent_children_count", + "unit_attribute": "family.n_dependent_children", + "value_source": "unit", + "reason": "harness: family_shape dependent_child_count (run.py:1937,1943)" + }, + { + "variable": "jobseeker_support_living_with_parent", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1944)" + }, + { + "variable": "jobseeker_support_partner_ineligible_due_to_sanction_or_strike", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1945)" + }, + { + "variable": "jobseeker_support_partner_receives_main_benefit", + "unit_attribute": "family.is_partnered", + "value_source": "unit", + "reason": "harness: family_shape partner_present (run.py:1946)" + }, + { + "variable": "jobseeker_support_partner_receives_new_zealand_superannuation_or_veterans_pension", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1947); superannuitant units follow superannuitant_units below" + }, + { + "variable": "jobseeker_support_single", + "unit_attribute": "family.is_single", + "value_source": "unit", + "reason": "harness: not partner_present (run.py:1948)" + }, + { + "variable": "jobseeker_support_transferred_2013_no_dependent_children", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1950)" + }, + { + "variable": "jobseeker_support_youngest_dependent_child_age", + "unit_attribute": "family.youngest_dependent_child_age", + "value_source": "unit", + "reason": "harness: family_shape youngest_child_age (run.py:1938,1951)" + }, + { + "variable": "jobseeker_support_total_income", + "value": 500, + "value_source": "bridge_point", + "reason": "the harness anchor (run.py:2502)" + } + ] + }, + { + "id": "payment_after_step", + "binding": "main_benefits_rates", + "variable": "jobseeker_support_net_weekly_payment", + "engine_entity": "Person", + "input_overrides": [ + { + "variable": "jobseeker_support_applicant_age", + "value": 25, + "value_source": "harness_constant", + "reason": "harness constant for IncomeExplorer stylized adults (run.py:1941); open item: whether reg 17 uses each adult's own age-dependent rate is unresolved (reg 17 text not read in this lane)" + }, + { + "variable": "jobseeker_support_benefit_commenced_on_or_after_1998_07_01", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1942)" + }, + { + "variable": "jobseeker_support_dependent_children_count", + "unit_attribute": "family.n_dependent_children", + "value_source": "unit", + "reason": "harness: family_shape dependent_child_count (run.py:1937,1943)" + }, + { + "variable": "jobseeker_support_living_with_parent", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1944)" + }, + { + "variable": "jobseeker_support_partner_ineligible_due_to_sanction_or_strike", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1945)" + }, + { + "variable": "jobseeker_support_partner_receives_main_benefit", + "unit_attribute": "family.is_partnered", + "value_source": "unit", + "reason": "harness: family_shape partner_present (run.py:1946)" + }, + { + "variable": "jobseeker_support_partner_receives_new_zealand_superannuation_or_veterans_pension", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1947); superannuitant units follow superannuitant_units below" + }, + { + "variable": "jobseeker_support_single", + "unit_attribute": "family.is_single", + "value_source": "unit", + "reason": "harness: not partner_present (run.py:1948)" + }, + { + "variable": "jobseeker_support_transferred_2013_no_dependent_children", + "value": false, + "value_source": "harness_constant", + "reason": "harness constant (run.py:1950)" + }, + { + "variable": "jobseeker_support_youngest_dependent_child_age", + "unit_attribute": "family.youngest_dependent_child_age", + "value_source": "unit", + "reason": "harness: family_shape youngest_child_age (run.py:1938,1951)" + }, + { + "variable": "jobseeker_support_total_income", + "value": 501, + "value_source": "bridge_point", + "reason": "anchor plus one dollar (run.py:2504)" + } + ] + } + ], + "composition": { + "reduction_per_dollar": "payment_at_anchor - payment_after_step", + "cutout": "500 + payment_at_anchor / reduction_per_dollar", + "refuse_when": "payment_at_anchor <= 0 or reduction_per_dollar <= 0" + }, + "exactness": "Reg 18 asks for the income that extinguishes Jobseeker Support (run.py:2513-2514). The extrapolation equals that zero when the net-payment schedule is linear between the anchor and its zero: Income Tests 3 and 4 have one rate (rates.yaml). Income Test 1 has two, but these overrides select it only for single units with children, which take the other branch." + } + ] + } + ], + "superannuitant_units": { + "central": "exclude_and_count", + "scenario_s5": "reg17_superannuitant_base_rate", + "status": "The harness has no NZ Superannuation or Veteran's Pension base-rate branch. G7 implements one from the reg 17 text if it can; otherwise the MC11 fallback stands: superannuitant units are excluded from the central run and counted. Scenario S5 carries the alternative. Awaiting D1 (MC11)." + }, + "beneficiary_note": "The harness computes the base rate from zero-income Jobseeker Support for every scenario, beneficiary or not; accommodation_supplement_non_beneficiary itself comes from the receipt layer through the group encode, not from this bridge.", + "differential_expectations": { + "source": { + "repository": "TheAxiomFoundation/axiom-oracles", + "commit": "6275afcdbe7590d0a56298c4916970a46ed936d0", + "path": "conformance/executable/nz-treasury-incomeexplorer/requests.json", + "sha256": "ed83b26c3221c253b13a9df307567e09948b2e9337ff5289729276b6b6ed6272" + }, + "cases": [ + { + "request_id": "golden-08", + "entity_id": "single_parent_three_children_area1_rent:as:statutory:0", + "branch": "sole_parent", + "accommodation_supplement_base_rate_weekly_amount": "673.8469230769230769230769230769230769231", + "accommodation_supplement_non_beneficiary_income_cutout_weekly_amount": "905.0285714285714285714285714285714285714", + "tolerance_note": "decimal strings as committed; float64 graph columns reproduce them within a tolerance G7 states" + } + ] + } +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/axiom_rules_bindings.json b/packages/microcosm-build/src/microcosm/build/nz/axiom_rules_bindings.json new file mode 100644 index 0000000000..43acffbd1b --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/axiom_rules_bindings.json @@ -0,0 +1,134 @@ +{ + "version": 1, + "country": "nz", + "description": "Axiom rules bindings for the New Zealand v0 graph build. Each binding names one rulespec-nz module by repository-relative rulespec_path and SHA-256 at the pinned commit, the Frame entity it runs on, the period label it is evaluated for, and the variables the graph requests. The shared composer builds one AxiomEngine per binding and registers every binding in one simulate.rules_by_ref@1 kernel, because a run holds one kernel per ref. The adapter refuses modules that declare relations; every bound module declares none at the pin. No kernel implementation hash binds the whole NZ spec.", + "rulespec": { + "repository": "TheAxiomFoundation/rulespec-nz", + "commit": "8dc2507d5f10a0467c285fed54d018ee37246798", + "branch": "main", + "verified_on": "2026-10-07", + "verification": "The commit is the merge of rulespec-nz PR #122 and is contained in origin/main (git branch -r --contains). The tree is exported with git archive, so the graph source carries no .git directory.", + "method_card_row": "MC1" + }, + "engine": { + "repository": "TheAxiomFoundation/axiom-rules-engine", + "commit": null, + "wheel_sha256": null, + "status": "awaiting the hub engine pin at M0 (D1 method card MC1); the composer refuses to run until both pins are set" + }, + "entity_names": { + "person": "Person", + "household": "Household", + "family": "Family" + }, + "periods": { + "2026-27": { + "kind": "tax_year", + "start": "2026-04-01", + "end": "2027-03-31" + } + }, + "input_surface": { + "fixture": "packages/microcosm-frame/tests/fixtures/axiom_input_surfaces/nz.json", + "notes": "The committed input surface was generated at rulespec-nz 6fe181fc (the fixture records the full commit), an ancestor of the pinned commit on main; git diff 6fe181fc 8dc2507 is empty for every bound module, so the SHA-256 values agree. root_input_count is the size of each module's canonical input surface." + }, + "bindings": [ + { + "id": "main_benefits_entitlement", + "rulespec_path": "nz/statutes/social_security/main_benefits/entitlement.yaml", + "sha256": "94332c778b3b5324798f67cb1b30f59c2af4131be8159455814f453bc814f8d0", + "entity": "person", + "engine_entity": "Person", + "period": "2026-27", + "role": "receipt_layer_eligibility", + "variables": [ + "entitled_to_jobseeker_support", + "entitled_to_sole_parent_support", + "entitled_to_supported_living_payment_restricted_or_blind", + "entitled_to_supported_living_payment_caring" + ], + "relations_declared": false, + "imports_declared": false, + "root_input_count": 60, + "notes": "Entitlement predicates only; the weekly rates and income-test reductions live in the rates module. Undetermined judgments feed take-up through the G4 input-closure table." + }, + { + "id": "main_benefits_rates", + "rulespec_path": "nz/statutes/social_security/main_benefits/rates.yaml", + "sha256": "7c8ebbc3e62268722529ad790e3f561bbebf807d8a4d67bba24f93cb0809ec39", + "entity": "person", + "engine_entity": "Person", + "period": "2026-27", + "role": "receipt_layer_rates_and_as_rate_bridge", + "variables": [ + "jobseeker_support_net_weekly_payment", + "sole_parent_support_net_weekly_payment", + "supported_living_payment_net_weekly_payment", + "jobseeker_support_gross_weekly_rate", + "jobseeker_support_weekly_income_test_reduction" + ], + "relations_declared": false, + "imports_declared": false, + "root_input_count": 28, + "notes": "Also evaluated by the reg 17/18 bridge (as_rate_bridge.json) under declared input overrides." + }, + { + "id": "nz_super", + "rulespec_path": "nz/statutes/new_zealand_superannuation/core.yaml", + "sha256": "d8ea0d15d97081031dcba242e66704c800bcf295e19d73bd173c56bd92d497b4", + "entity": "person", + "engine_entity": "Person", + "period": "2026-27", + "role": "receipt_layer_eligibility", + "variables": [ + "entitled_to_new_zealand_superannuation", + "nz_super_weekly_amount_before_tax" + ], + "relations_declared": false, + "imports_declared": false, + "root_input_count": 13, + "notes": "NZ Superannuation eligibility for the O4-calibrated receipt layer (MC6)." + }, + { + "id": "accommodation_supplement", + "rulespec_path": "nz/statutes/social_security/accommodation_supplement/core.yaml", + "sha256": "f2feca3c2f1de71623ccd577cd2aed20e8822614aac0ccfc767b5faf4eaa83cc", + "entity": "family", + "engine_entity": "Family", + "period": "2026-27", + "role": "entitlement", + "variables": [ + "accommodation_supplement_rounded_weekly_payment", + "accommodation_supplement_weekly_amount_before_rounding", + "accommodation_supplement_non_rate_entitlement_requirements", + "accommodation_supplement_cash_assets_within_limit", + "accommodation_supplement_social_housing_exclusion_not_applies", + "accommodation_supplement_other_funding_exclusion_not_applies", + "accommodation_supplement_non_beneficiary_income_reduction", + "accommodation_supplement_weekly_qualifying_accommodation_costs", + "accommodation_supplement_maximum_weekly_rate" + ], + "relations_declared": false, + "imports_declared": false, + "root_input_count": 39, + "notes": "The module takes the reg 17 base rate and reg 18 non-beneficiary cutout as inputs (its summary: \"for later cross-module wiring\"); as_rate_bridge.json supplies them until rulespec-nz encodes regs 17 and 18. Calibration-tier variants add their own AS nodes." + }, + { + "id": "family_tax_credits", + "rulespec_path": "nz/statutes/income_tax/family_scheme/tax_credits.yaml", + "sha256": "d4bde4b4a72ec495dc516af1c95c9f438c0778d4f07cc02f64f242532a6efe05", + "entity": "family", + "engine_entity": "Family", + "period": "2026-27", + "role": "as_rate_bridge_only", + "variables": [ + "family_tax_credit_prescribed_amount" + ], + "relations_declared": false, + "imports_declared": false, + "root_input_count": 21, + "notes": "Used only for the eldest-child family tax credit amount in the reg 17 base rate. The module also declares 4 rules on an engine Child entity that the NZ schema does not map; the bridge requests none of them, and G1/G7 must show the adapter compiles the module under that condition or stop." + } + ], + "keying_note": "An Axiom-bound node carries its own binding entry, the rulespec and engine pin blocks, the entity names and its period, plus a digest of exactly those values, never this file's SHA-256. Otherwise an Accommodation Supplement edit would re-key the main-benefit and NZ Super nodes, which sit upstream of calibration. Which graph nodes consume which binding is the composer's business, not this resource's." +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/benefit_unit_rule.json b/packages/microcosm-build/src/microcosm/build/nz/benefit_unit_rule.json new file mode 100644 index 0000000000..7462eec5f7 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/benefit_unit_rule.json @@ -0,0 +1,82 @@ +{ + "version": 1, + "country": "nz", + "method_card_row": "MC7", + "description": "The New Zealand benefit unit (engine Family entity): an adult, their co-resident partner, and their dependent children; every other adult forms their own unit. Entity ids and person-to-unit memberships are structural graph columns that only a CREATE node may own, so the shared transport CREATE kernel builds the units from the donor concept pointers, refusing ambiguous or cross-household units. Family weights are never stored: each unit inherits its household's weight through the Frame.", + "status": "awaiting D1 (MC7; the dependent-child rule is spec data under amendment MC11a)", + "entity": "family", + "engine_entity": "Family", + "parent_entity": "household", + "membership_columns": { + "family_id": "family.family_id", + "family_household_id": "family.family_household_id", + "person_family_id": "person.person_family_id" + }, + "pointer_concepts": { + "partner": "partner_person_id", + "parents": [ + "parent_1_person_id", + "parent_2_person_id" + ], + "household_reference_person": "reference_person_id", + "citation": "packages/microcosm-frame/src/microcosm/frame/concepts.py:880-919" + }, + "composition": [ + { + "member": "adult", + "rule": "each person who is not a dependent child anchors exactly one unit unless they join a partner's unit" + }, + { + "member": "partner", + "rule": "a co-resident partner (partner_person_id, a symmetric pointer) joins the adult's unit; partners always share a unit" + }, + { + "member": "dependent_child", + "rule": "a dependent child joins the unit of a co-resident parent: parent_1_person_id, or parent_2_person_id when parent 1 is absent" + }, + { + "member": "other_adult", + "rule": "every other adult in the household, including boarders and flatmates, forms their own unit" + } + ], + "dependent_child": { + "age_limit_years": null, + "candidate_age_limit_years": 18, + "candidate_basis": "UNVERIFIED proposal. The Social Security Act 2018 definitions of \"child\" and \"dependent child\" were not read in this lane. The hub confirms the statutory text before D1; the CREATE kernel must refuse a null age_limit_years.", + "partnered_person_is_dependent_child": false, + "rulespec_encoding": "None at rulespec-nz 8dc2507: nz/statutes/common/relationships.yaml has status deferred and rules: [], and main_benefits/entitlement.yaml encodes only sole_parent_dependent_child_age_limit for the sole-parent test. When rulespec-nz encodes the definition, the CREATE kernel reads it from there and this block is deleted." + }, + "child_without_coresident_parent": { + "central": "household_reference_person_unit", + "status": "proposed default for D1; the kernel refuses when the reference person is themselves a dependent child" + }, + "refusals": [ + "a unit whose members span two households", + "a parent or partner pointer to a person outside the household", + "an asymmetric partner pointer", + "a dependent child in a household with no adult" + ], + "open_cases": [ + "a person under the age limit who has a co-resident child of their own (teen parent): adult or dependent child is a D1 call", + "a child whose two co-resident parents are not partners: the rule above places the child with parent 1" + ], + "invariants": [ + "every person is in exactly one unit", + "every unit has at least one adult", + "partners share a unit", + "every dependent child shares a unit with a parent or the reference person", + "units nest in households", + "sum of family weights equals the sum over households of household weight times units in the household" + ], + "unit_attributes": { + "note": "Unit attributes the units-attributes node emits on the family entity; the reg 17/18 bridge and the group encode read only these.", + "columns": { + "family.family_type": "category of the MC7 unit: single, couple, sole parent, couple with children", + "family.n_dependent_children": "count of dependent children in the unit", + "family.is_sole_parent": "single adult with at least one dependent child", + "family.is_partnered": "the unit contains a partnered couple", + "family.is_single": "the unit has one adult (not family.is_partnered)", + "family.youngest_dependent_child_age": "age of the youngest dependent child; 0 when there is none" + } + } +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/country_package.json b/packages/microcosm-build/src/microcosm/build/nz/country_package.json new file mode 100644 index 0000000000..2b3db06c9a --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/country_package.json @@ -0,0 +1,106 @@ +{ + "schema_version": 1, + "country": "nz", + "resources": [ + { + "path": "spec/bundle.yaml", + "kind": "bundle", + "schema_id": "bundle.schema.json" + }, + { + "path": "spec/catalogs.yaml", + "kind": "catalogs", + "schema_id": "catalogs.schema.json" + }, + { + "path": "spec/geography.yaml", + "kind": "geography", + "schema_id": "geography.schema.json" + }, + { + "path": "spec/sources.yaml", + "kind": "sources", + "schema_id": "sources.schema.json" + }, + { + "path": "spec/spine.yaml", + "kind": "spine", + "schema_id": "spine.schema.json" + }, + { + "path": "spec/vintages.yaml", + "kind": "vintages", + "schema_id": "vintages.schema.json" + }, + { + "path": "as_area_crosswalk.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "as_rate_bridge.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "axiom_rules_bindings.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "benefit_unit_rule.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "currency_bridge.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "export_contract.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "gates.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "geography_spine.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "holdout_references.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "precal_references.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "release_contract.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "scenarios.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "source_stages.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + }, + { + "path": "target_references.json", + "kind": "legacy_json", + "schema_id": "legacy_json" + } + ] +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/currency_bridge.json b/packages/microcosm-build/src/microcosm/build/nz/currency_bridge.json new file mode 100644 index 0000000000..37e983196f --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/currency_bridge.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "country": "nz", + "method_card_row": "MC4", + "description": "The declared USD to NZD bridge for donor money concepts outside the quantile-map set (MC4 quantile-maps earnings, rent and liquid assets onto New Zealand distributions instead). It is exactly one multiplication with two-decimal rounding, applied before calibration and recorded in the build receipt. Aging from the donor dollar year to tax year 2026-27 is a separate declared index (MC1, MC5), not part of this bridge.", + "status": "awaiting D1 (MC4)", + "from_currency": "USD", + "to_currency": "NZD", + "operation": "multiply", + "rate": 1.654, + "rate_unit": "NZD per USD", + "rounding": { + "decimal_places": 2 + }, + "rate_year": 2024, + "rate_year_basis": "The donor dollar year: the pinned populace-us support artifact has survey period 2024 (source_stages.json artifact vintage \"2024\"; spec/vintages.yaml nz_support_pool_2024_buildp).", + "source": { + "publisher": "Internal Revenue Service", + "title": "Yearly average currency exchange rates", + "url": "https://www.irs.gov/individuals/international-taxpayers/yearly-average-currency-exchange-rates", + "table": "Yearly average exchange rates for converting foreign currencies into U.S. dollars", + "row": "New Zealand, Dollar", + "column": "2024", + "conversion_rule": "\"To convert from U.S. dollars to foreign currency, multiply the U.S. dollar amount by the applicable yearly average exchange rate in the table below.\"", + "page_last_reviewed": "2026-09-17", + "retrieved_on": "2026-10-07", + "retrieved_sha256": "8c765c8c88a6a6ad4fe325a0344ff69d2fa2ab174dcac2e8c13e1a5ea747fff8", + "retrieved_note": "SHA-256 of the HTML bytes fetched on the retrieval date; the page is re-rendered by the publisher, so it is a receipt, not a stable content key." + } +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/export_contract.json b/packages/microcosm-build/src/microcosm/build/nz/export_contract.json new file mode 100644 index 0000000000..83f97703bf --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/export_contract.json @@ -0,0 +1,91 @@ +{ + "_description": "Closed Frame export contract for the New Zealand v0 population dataset, which the CLI writes outside the graph after the export-prepare node. Donor records remain US support records; the dataset is never described as New Zealand microdata. The required set is the structural and household-weight columns. Encoded engine-input columns enter only when the G4 input-closure table names them and this contract lists them, so an undeclared column fails the export gate. formula_owned_excluded is exactly the set of variables the graph requests from the bound rulespec-nz modules (axiom_rules_bindings.json): no rule output may persist as an input.", + "_source": { + "rules_bindings": "axiom_rules_bindings.json", + "unit_rule": "benefit_unit_rule.json" + }, + "_schema": { + "entities": [ + "person", + "household", + "family" + ], + "engine_entity_names": { + "person": "Person", + "household": "Household", + "family": "Family" + }, + "structural_columns": { + "person": [ + "person_id", + "person_household_id", + "person_family_id" + ], + "household": [ + "household_id" + ], + "family": [ + "family_id", + "family_household_id" + ] + } + }, + "_period": { + "label": "2026-27", + "period_kind": "tax_year", + "start": "2026-04-01", + "end": "2027-03-31", + "dataset_time_period": 2026 + }, + "_weight_contract": { + "stored_weight_entity": "household", + "stored_weight_column": "household_weight", + "family_weight_column_stored": false, + "family_weight_resolution": "The frame must call resolve_weights('family'); each family must map to exactly one household and inherit that household's calibrated weight.", + "forbid_manual_person_weight_aggregation": true + }, + "required": [ + "person_id", + "person_household_id", + "person_family_id", + "household_id", + "household_weight", + "family_id", + "family_household_id" + ], + "forbidden": [ + "person_weight", + "family_weight" + ], + "optional": [ + "age", + "sex", + "region_code", + "support_stratum", + "donor_country_code" + ], + "formula_owned_excluded": [ + "accommodation_supplement_cash_assets_within_limit", + "accommodation_supplement_maximum_weekly_rate", + "accommodation_supplement_non_beneficiary_income_reduction", + "accommodation_supplement_non_rate_entitlement_requirements", + "accommodation_supplement_other_funding_exclusion_not_applies", + "accommodation_supplement_rounded_weekly_payment", + "accommodation_supplement_social_housing_exclusion_not_applies", + "accommodation_supplement_weekly_amount_before_rounding", + "accommodation_supplement_weekly_qualifying_accommodation_costs", + "entitled_to_jobseeker_support", + "entitled_to_new_zealand_superannuation", + "entitled_to_sole_parent_support", + "entitled_to_supported_living_payment_caring", + "entitled_to_supported_living_payment_restricted_or_blind", + "family_tax_credit_prescribed_amount", + "jobseeker_support_gross_weekly_rate", + "jobseeker_support_net_weekly_payment", + "jobseeker_support_weekly_income_test_reduction", + "nz_super_weekly_amount_before_tax", + "sole_parent_support_net_weekly_payment", + "supported_living_payment_net_weekly_payment" + ], + "closed": true +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/gates.json b/packages/microcosm-build/src/microcosm/build/nz/gates.json new file mode 100644 index 0000000000..32d35045dd --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/gates.json @@ -0,0 +1,131 @@ +{ + "version": 2, + "country": "nz", + "policy": "New Zealand is greenfield and has no incumbent population, so parity, export_surface, and target_surface are absent. Public NZ facts and Treasury oracles govern the build. Gates whose thresholds are D1 method-card items (MC15 fit tolerances, effective sample size, weight ratio) stay not_applicable with the reason 'awaiting D1'; macro_realism waits on packaged evidence and reviewed bands. Thresholds are never borrowed from Belgium, the US, the UK or Armenia and never set from a candidate. Active identity, reference, support, weights, nonzero, formula-ownership, and nonnegative gates fail closed.", + "phases": [ + "terminal" + ], + "gates": [ + { + "id": "calibration_per_family_fit", + "gate": "per_family_fit", + "phase": "terminal", + "criticality": "release_blocking", + "not_applicable": "awaiting D1: the ex-ante NZ per-family tolerances (method card MC15) are not yet ruled, and Chronicle has not yet supplied the complete cell-expanded NZ target inventory or covariance. Belgium and shared runner defaults may not silently govern New Zealand.", + "notes": "Activate with explicit NZ policy after the target harvest. Every hard family must report separately so an easy demographic family cannot hide a weak income or programme family." + }, + { + "id": "national_and_region_admin_aggregates", + "gate": "aggregate_admin", + "phase": "terminal", + "criticality": "release_blocking", + "not_applicable": "awaiting D1: reviewed fact-specific tolerances are not yet ruled, and the complete Chronicle NZ fact rows, projection basis to tax year 2026-27, and uncertainty are not yet bound.", + "notes": "Activate at country and region levels only after every hard reference resolves. Consume table leaves or totals, never both as independent targets." + }, + { + "id": "calibration_reference_inventory", + "gate": "calibration_reference_coverage", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": {}, + "evidence_absent_blocks": true, + "notes": "Every table-level authoring contract must be replaced by generated cell-pinned Chronicle references with direct, prepared, or engine-derived bindings; every activated reference must enter the matrix." + }, + { + "id": "target_profile_coverage", + "gate": "target_profile_coverage", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": { + "required_families": [ + "demography", + "household_structure", + "housing_tenure", + "income", + "main_benefits", + "superannuation" + ] + }, + "evidence_absent_blocks": true, + "notes": "The active national profile must retain every family of the D1 MC5 calibration set (target_references.json). Accommodation Supplement and Working for Families references are held out (holdout_references.json) and must never be required here. Treasury model outputs (DistributionExplorer, IncomeExplorer) and official Budget reform scores are validation oracles, never calibration targets." + }, + { + "id": "release_input_coverage", + "gate": "release_input_coverage", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": {}, + "evidence_absent_blocks": true, + "notes": "Authenticate the exact Build P donor artifact and close every declared Frame/Axiom input. A missing substantive root input of any module bound in axiom_rules_bindings.json is a build failure, never an engine default." + }, + { + "id": "donor_support_bounds", + "gate": "support", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": {}, + "evidence_absent_blocks": true, + "notes": "Every direct or derived calibration measure and every substantive Axiom input must carry a donor-support receipt." + }, + { + "id": "calibration_weights_audit", + "gate": "weights_audit", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": {}, + "evidence_absent_blocks": true, + "notes": "Household weights must receipt their design-to-calibrated transition and destination-population mass change. Family scoring resolves those weights through Frame; it may not persist a second ambiguous vector." + }, + { + "id": "exported_nonzero", + "gate": "exported_nonzero", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": {}, + "evidence_absent_blocks": true, + "notes": "No all-zero stored calibration layer, substantive Axiom input, geography layer, or provenance banner may ship." + }, + { + "id": "formula_owned_export", + "gate": "formula_owned_export", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": {}, + "evidence_absent_blocks": true, + "notes": "No Axiom-derived rule output may persist as an input column and mask a baseline or reform calculation." + }, + { + "id": "nonnegative_columns", + "gate": "nonnegative_columns", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": {}, + "evidence_absent_blocks": true, + "notes": "The source manifest is authoritative for physically non-negative weights, counts, days, periods, care units, and currency inputs." + }, + { + "id": "weight_effective_sample_size", + "gate": "weight_ess", + "phase": "terminal", + "criticality": "release_blocking", + "not_applicable": "awaiting D1: the ex-ante NZ effective-sample-size floor (method card MC15) is not yet ruled, and no shared country-neutral evaluator binding exists yet.", + "notes": "Activate before a candidate release; never set the threshold from the candidate to make it pass." + }, + { + "id": "weight_max_to_median_ratio", + "gate": "weight_ratio", + "phase": "terminal", + "criticality": "release_blocking", + "not_applicable": "awaiting D1: the ex-ante NZ max-to-median weight-ratio threshold (method card MC15) is not yet ruled, and no shared country-neutral evaluator binding exists yet.", + "notes": "Activate before a candidate release; do not copy an empirical US, UK, Belgium, or Armenia fence." + }, + { + "id": "macro_realism_nz", + "gate": "macro_realism", + "phase": "terminal", + "criticality": "release_blocking", + "not_applicable": "The Stats NZ national-accounts quantities, destination-unit candidate metrics, reviewed concept bridges, and low/high NZ bands are not yet packaged.", + "notes": "Activate broad income and expenditure backstops after evidence review. Treasury DistributionExplorer remains an external model comparator, not a macro target." + } + ] +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/geography_spine.json b/packages/microcosm-build/src/microcosm/build/nz/geography_spine.json new file mode 100644 index 0000000000..f05d7c7b0b --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/geography_spine.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "country": "nz", + "policy": "One national New Zealand dataset carries Stats NZ region codes for calibration and filtering. Region is target-assigned on US donor support records, never represented as observed donor geography. In the graph build each household draws one atomic unit (territorial authority intersected with Accommodation Supplement area) from population-weighted support built from Chronicle territorial-authority population facts and as_area_crosswalk.json; region, territorial authority and Accommodation Supplement area derive from that unit. Finer layers remain columns on this same national dataset, not separate files.", + "geography_spine": { + "stage": "assign_nz_regions", + "method": "clone_assign_uniform", + "geography_level": "region", + "code_system": "nz_stats_region", + "code_column": "region_code", + "vintage": "2025", + "vintage_policy": "error", + "clones_per_record": 1, + "collision_avoidance": true, + "constrain_to_column": "", + "assignment_source": { + "survey": "Stats NZ subnational population estimates at 30 June 2025", + "source": "https://www.stats.govt.nz/information-releases/subnational-population-estimates-at-30-june-2025/", + "notes": "Region follows from each household's atomic territorial-authority by Accommodation Supplement area draw, whose support weights come from Stats NZ population facts, and is broadcast to family and person tables; the age-by-sex-by-region cells are calibration targets. Chronicle must supply the complete current region and territorial-authority rosters and cell-level facts before the operator can run." + }, + "notes": "One clone per record is the compile-safe national starting point. This generation-0 record describes the region layer only; the shared graph geography kernels execute assignment. The exact 16-region roster, the geography source artifact identity, the harvested territorial-authority to Accommodation Supplement area shares, and reviewed support diagnostics must land before execution." + } +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/holdout_references.json b/packages/microcosm-build/src/microcosm/build/nz/holdout_references.json new file mode 100644 index 0000000000..bc2d66492b --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/holdout_references.json @@ -0,0 +1,176 @@ +{ + "country": "nz", + "reference_set": "holdout", + "description": "New Zealand v0 held-out references: Accommodation Supplement comparators (recipients, MSD Accommodation Assistance expenditure, Treasury AN 24/01) and the Working for Families tripwire. MC5 holds these out of calibration; no calibration-side graph node may read this resource. target_role is validation throughout. Every row is an unactivated placeholder. Each period follows the label convention of its Chronicle package (IRD tax years by closing year; MSD fiscal years by opening year, as recorded in source_period_kind, source_period_start and source_period_end); activation re-checks every period against the harvested facts.", + "allowed_value_operations": [ + "identity" + ], + "target_references": [ + { + "name": "msd_accommodation_supplement_recipients_by_region", + "ledger_selector": { + "source_name": "msd_quarterly_benefit_facts", + "source_measure_id": "accommodation_supplement_recipients_by_region", + "geography_level": "region" + }, + "entity": "family", + "measure": "receives_accommodation_supplement", + "period": "2026-03", + "family": "accommodation_supplement", + "metadata": { + "activation_status": "requires_harvested_cell_references_and_geography_bridge", + "criticality": "diagnostic", + "measure_kind": "prepared_column", + "publisher": "Ministry of Social Development", + "target_role": "validation", + "reference_set": "holdout", + "method_card_row": "MC5", + "holdout_use": "accommodation_supplement_recipient_comparator" + }, + "notes": "Held out (MC5, conflict C5): calibrating to Accommodation Supplement recipients would predetermine the take-up comparison. MSD Work and Income regions are not Stats NZ regions, so comparison needs an explicit geography bridge. Compared with modelled eligible units, never used as a target." + }, + { + "name": "msd_annual_report_accommodation_assistance_expenditure", + "ledger_selector": { + "source_name": "msd_annual_report", + "source_measure_id": "benefits_or_related_expenses_accommodation_assistance", + "geography_level": "country" + }, + "entity": "family", + "measure": "accommodation_supplement_annual_amount", + "period": 2024, + "family": "accommodation_supplement", + "metadata": { + "activation_status": "requires_harvested_fact_reference_and_scope_evidence", + "measure_kind": "prepared_column", + "publisher": "Ministry of Social Development", + "source_period_kind": "fiscal_year", + "source_period_start": "2024-07-01", + "source_period_end": "2025-06-30", + "target_role": "validation", + "criticality": "diagnostic", + "reference_set": "holdout", + "method_card_row": "MC2", + "holdout_use": "accommodation_supplement_dollar_comparator" + }, + "notes": "MSD Annual Report 2025, Benefits or Related Expenses, 2024/25 actual, published line \"Accommodation Assistance\" (targets doc M2). The comparator for the annual dollar gap (MC2). The line label may cover more than the Accommodation Supplement; the Chronicle package must record the appropriation definition before any statement that calls it Accommodation Supplement paid. Period 2024 is the opening year of the July-June fiscal year." + }, + { + "name": "treasury_an24_01_accommodation_supplement_fiscal_total", + "ledger_selector": { + "source_name": "treasury_an24_01", + "source_measure_id": "fiscal_totals_accommodation_supplement", + "geography_level": "country" + }, + "entity": "family", + "measure": "accommodation_supplement_annual_amount", + "period": 2019, + "family": "accommodation_supplement", + "metadata": { + "activation_status": "requires_harvested_fact_reference", + "measure_kind": "prepared_column", + "publisher": "The Treasury", + "source_period_kind": "tax_year", + "source_period_start": "2018-04-01", + "source_period_end": "2019-03-31", + "target_role": "validation", + "criticality": "diagnostic", + "reference_set": "holdout", + "method_card_row": "MC2", + "holdout_use": "accommodation_supplement_dollar_comparator" + }, + "notes": "Treasury Analytical Note AN 24/01 fiscal_totals.csv, Accommodation Supplement row, tax year 2018/19 (targets doc T4, CC0). A secondary historical comparator only; it is never scaled or uprated to the model year." + }, + { + "name": "ird_wff_recipient_families", + "ledger_selector": { + "source_name": "ird", + "source_measure_id": "wff_recipient_families", + "geography_level": "country" + }, + "entity": "family", + "measure": "receives_working_for_families", + "period": 2024, + "family": "working_for_families", + "metadata": { + "activation_status": "requires_receipt_assignment_wff_bindings_and_2026_basis", + "chronicle_package_id": "ird-working-for-families-statistics-sept-2025", + "criticality": "diagnostic", + "measure_kind": "prepared_column", + "publisher": "Inland Revenue", + "target_role": "validation", + "chronicle_package_path": "ird/working_for_families_statistics_sept_2025", + "source_period_kind": "tax_year", + "source_period_start": "2023-04-01", + "source_period_end": "2024-03-31", + "reference_set": "holdout", + "method_card_row": "MC5", + "holdout_use": "working_for_families_tripwire" + }, + "notes": "Held out as the Working for Families tripwire (IRD WFF statistics, targets doc I3). Cell-pinned IRD administrative count of families with a claim and non-zero final entitlement, not every eligible family and not cash paid. Chronicle owns the September 2025 workbook facts. Activation requires a separate family receipt/claim state and an explicit bridge from tax year ending 2024 to the 2026-27 build basis. Total and average entitlement NZD remain held-out validation, never calibration values. No v0 graph node computes WFF, so the comparison is parked until the WFF modules are bound.", + "ledger_source_record_id": "ird_wff_statistics.ty2024.recipient_families.all.wff_recipient_families" + }, + { + "name": "ird_iwtc_recipient_families", + "ledger_selector": { + "source_name": "ird", + "source_measure_id": "iwtc_recipient_families", + "geography_level": "country" + }, + "entity": "family", + "measure": "receives_in_work_tax_credit", + "period": 2024, + "family": "working_for_families", + "metadata": { + "activation_status": "requires_receipt_assignment_wff_bindings_and_2026_basis", + "chronicle_package_id": "ird-working-for-families-statistics-sept-2025", + "criticality": "diagnostic", + "measure_kind": "prepared_column", + "publisher": "Inland Revenue", + "target_role": "validation", + "chronicle_package_path": "ird/working_for_families_statistics_sept_2025", + "source_period_kind": "tax_year", + "source_period_start": "2023-04-01", + "source_period_end": "2024-03-31", + "reference_set": "holdout", + "method_card_row": "MC5", + "holdout_use": "working_for_families_tripwire" + }, + "notes": "Held out as the Working for Families tripwire (IRD WFF statistics, targets doc I3). Cell-pinned IRD administrative count of families with a claim and non-zero final entitlement, not every eligible family and not cash paid. Chronicle owns the September 2025 workbook facts. Activation requires a separate family receipt/claim state and an explicit bridge from tax year ending 2024 to the 2026-27 build basis. Total and average entitlement NZD remain held-out validation, never calibration values. No v0 graph node computes WFF, so the comparison is parked until the WFF modules are bound.", + "ledger_source_record_id": "ird_wff_statistics.ty2024.recipient_families.all.iwtc_recipient_families" + }, + { + "name": "ird_wff_recipient_families_by_family_income_band", + "ledger_selector": { + "source_name": "ird", + "source_measure_id": "wff_recipient_families_by_family_income_band", + "geography_level": "country" + }, + "entity": "family", + "measure": "receives_working_for_families", + "filter": "family_scheme_income_band_indicator", + "period": 2024, + "family": "working_for_families", + "metadata": { + "activation_status": "requires_harvested_cell_references_wff_bindings_and_2026_basis", + "measure_kind": "prepared_column", + "publisher": "Inland Revenue", + "chronicle_package_id": "ird-working-for-families-statistics-sept-2025", + "chronicle_package_path": "ird/working_for_families_statistics_sept_2025", + "source_period_kind": "tax_year", + "source_period_start": "2023-04-01", + "source_period_end": "2024-03-31", + "target_role": "validation", + "criticality": "diagnostic", + "reference_set": "holdout", + "method_card_row": "MC5", + "holdout_use": "working_for_families_tripwire" + }, + "notes": "IRD WFF recipient-family income distribution (targets doc I3, 2020 onward): the one published conditional slice of the joint-income distribution, reported as the joint-income tripwire. Held out; never calibration." + } + ], + "target_profile": { + "schema_version": 1, + "hierarchy_reconciliations": [] + } +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/precal_references.json b/packages/microcosm-build/src/microcosm/build/nz/precal_references.json new file mode 100644 index 0000000000..fa27f76858 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/precal_references.json @@ -0,0 +1,156 @@ +{ + "country": "nz", + "reference_set": "precal", + "description": "New Zealand v0 pre-calibration references: destination distributions for the transport quantile maps (earnings, rents, liquid assets), territorial-authority population for the atomic geography support, and the MSD recipient counts that calibrate the take-up draws (MC6). They are read before weight calibration and are kept apart from target_references.json so that adding a calibration target does not re-run transport. Every row is an unactivated placeholder.", + "allowed_value_operations": [ + "identity" + ], + "target_references": [ + { + "name": "ird_wage_salary_people_by_band", + "ledger_selector": { + "source_name": "ird_wage_salary_statistics", + "source_measure_id": "individuals_by_wage_salary_band", + "geography_level": "country" + }, + "entity": "person", + "measure": "people", + "filter": "wage_salary_band_indicator", + "period": 2024, + "family": "earnings", + "metadata": { + "activation_status": "requires_harvested_cell_references_candidate_column_and_2026_basis", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Inland Revenue", + "target_role": "pre_calibration", + "reference_set": "precal", + "method_card_row": "MC4", + "precal_use": "employment_income_quantile_map_bands" + }, + "notes": "IRD wage and salary bands are the destination distribution for the employment-income quantile map (MC4), not a weight-calibration target. Each band needs a destination-NZD indicator and source-period bridge." + }, + { + "name": "mbie_bond_rents_by_territorial_authority", + "ledger_selector": { + "source_name": "mbie_tenancy_bond_rents", + "source_measure_id": "rent_distribution_by_ta_bedrooms_dwelling_type", + "geography_level": "territorial_authority" + }, + "entity": "household", + "measure": "rent", + "period": null, + "family": "housing_costs", + "metadata": { + "activation_status": "requires_harvested_cell_references_month_pin_and_stock_evidence", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Ministry of Business, Innovation and Employment", + "target_role": "pre_calibration", + "reference_set": "precal", + "method_card_row": "MC8", + "precal_use": "rent_quantile_map_bands" + }, + "notes": "MBIE tenancy bond rents by territorial authority, bedrooms and dwelling type (targets doc H3) are the destination distribution for the within-region rent quantile map. Chronicle pins the month and records whether the rents describe newly lodged bonds or the active stock; that evidence decides scenario S4. Period is null until the harvest pins the month.", + "filter": "rent_band_indicator" + }, + { + "name": "stats_nz_household_financial_net_worth_distribution", + "ledger_selector": { + "source_name": "stats_nz_household_net_worth", + "source_measure_id": "household_financial_assets_distribution", + "geography_level": "country" + }, + "entity": "household", + "measure": "liquid_financial_assets", + "period": null, + "family": "wealth", + "metadata": { + "activation_status": "requires_new_chronicle_package_and_harvested_cell_references", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Stats NZ", + "target_role": "pre_calibration", + "reference_set": "precal", + "method_card_row": "MC10", + "precal_use": "liquid_assets_quantile_map_bands" + }, + "notes": "Stats NZ household net worth statistics, financial-asset distribution: the destination for the household-level liquid-asset quantile map (MC10a); members are rescaled pro rata and the unit sum feeds the Accommodation Supplement cash-asset test. The Chronicle package does not yet exist (6 Oct plan B6 part 2). Period is null until the release vintage is named.", + "filter": "financial_asset_band_indicator" + }, + { + "name": "stats_nz_population_by_territorial_authority", + "ledger_selector": { + "source_name": "stats_nz_population_estimates", + "source_measure_id": "estimated_resident_population_by_territorial_authority", + "geography_level": "territorial_authority" + }, + "entity": "person", + "measure": "people", + "period": 2025, + "family": "demography", + "metadata": { + "activation_status": "requires_harvested_cell_references", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Stats NZ", + "target_role": "pre_calibration", + "geography_vintage": "ta_2025", + "reference_set": "precal", + "method_card_row": "MC9", + "precal_use": "atomic_geography_support" + }, + "notes": "Territorial-authority resident population (targets doc S7) builds the population-weighted territorial authority by Accommodation Supplement area support with as_area_crosswalk.json. It is a support weight, not a weight-calibration target." + }, + { + "name": "msd_main_benefit_recipients_by_program_age", + "ledger_selector": { + "source_name": "msd_quarterly_benefit_facts", + "source_measure_id": "main_benefit_recipients_by_program_age", + "geography_level": "country" + }, + "entity": "person", + "measure": "receives_main_benefit", + "period": "2026-03", + "family": "main_benefits", + "metadata": { + "activation_status": "requires_harvested_cell_references_and_take_up_assignment", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Ministry of Social Development", + "target_role": "pre_calibration", + "reference_set": "precal", + "method_card_row": "MC6", + "precal_use": "take_up_draw_calibration" + }, + "notes": "Count-only administrative cells constrain an explicit receipt/take-up layer. They must never be bound to Axiom full-entitlement outputs or used to force entitlement dollars to equal actual expenditure." + }, + { + "name": "msd_nz_super_veterans_pension_recipients", + "ledger_selector": { + "source_name": "msd_nz_super_veterans_pension_facts", + "source_measure_id": "nz_super_veterans_pension_recipients", + "geography_level": "country" + }, + "entity": "person", + "measure": "receives_nz_super_or_veterans_pension", + "period": "2025-06", + "family": "superannuation", + "metadata": { + "activation_status": "requires_harvested_fact_reference_and_take_up_assignment", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Ministry of Social Development", + "target_role": "pre_calibration", + "reference_set": "precal", + "method_card_row": "MC6", + "precal_use": "take_up_draw_calibration" + }, + "notes": "Recipient stock is a receipt-state target, not an entitlement-output target. Expenditure is validation evidence and stays outside the calibration manifest." + } + ], + "target_profile": { + "schema_version": 1, + "hierarchy_reconciliations": [] + } +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/release_contract.json b/packages/microcosm-build/src/microcosm/build/nz/release_contract.json new file mode 100644 index 0000000000..ca0ed43cec --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/release_contract.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "country": "nz", + "policy": "New Zealand v0 is a public synthetic transport artifact: its unit records derive only from the public Microcosm US support release and its calibration facts are public New Zealand aggregates. Every record and public presentation must retain the US-donor/support-stratum provenance banner and must never be described as observed New Zealand microdata. Releases use data_build_id plus immutable Hugging Face revision, never ordinal versions.", + "builder": "populace-nz", + "hf": { + "artifact_repo": "policyengine/populace-nz", + "private": false, + "staging_repo": "policyengine/populace-nz-staging" + }, + "release_manifest_country": { + "code": "nz", + "label": "New Zealand", + "geography_id": null, + "geography_label": "New Zealand", + "repository_visibility": "public", + "capabilities": [ + "calibration", + "targets", + "compare" + ] + }, + "dataset_filename_template": "populace_nz_{year}.h5", + "required_release_files": [ + "populace_nz_{year}.h5", + "populace_nz_{year}_calibration.npz", + "build_manifest.json", + "release_manifest.json", + "calibration_diagnostics.json", + "source_coverage.json", + "demographics.json", + "reform_validation.json" + ], + "boundary": { + "private": [], + "public": [ + "populace_nz_{year}.h5", + "populace_nz_{year}_calibration.npz", + "build_manifest.json", + "release_manifest.json", + "calibration_diagnostics.json", + "source_coverage.json", + "demographics.json", + "reform_validation.json", + "latest.json" + ] + }, + "licence": { + "name": "Microcosm US public synthetic release plus public NZ administrative aggregates", + "restricted": false, + "notes": "The donor artifact is the immutable public Build P release pinned in source_stages.json. Chronicle owns per-source licence evidence for each NZ aggregate. Any future HES CURF, IDI, or other restricted unit-record contribution requires a separate private-repository and destination-guard review before it enters this contract." + }, + "notes": "The target dataset period is tax year 2026-27, represented by 2026 in the current single-year kernel. The generic release assembler must copy release_manifest_country to release_manifest.json.country exactly. The dashboard registry reads policyengine/populace-nz/latest.json; the staging repository is for build telemetry. calibration_diagnostics.json is the published target-fit surface and reform_validation.json carries the Treasury official-score comparisons with explicit entitlement-to-operating-cost bridges. The two Hugging Face repositories and generic NZ release assembler do not yet exist, so this contract authorizes no publication by itself." +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/scenarios.json b/packages/microcosm-build/src/microcosm/build/nz/scenarios.json new file mode 100644 index 0000000000..b947b1981a --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/scenarios.json @@ -0,0 +1,168 @@ +{ + "version": 1, + "country": "nz", + "method_card_row": "MC3", + "description": "The declared Accommodation Supplement scenario set (method card amendment MC3a): entitlement-tier scenarios S0-S5 branch from the shared AS version after calibration; calibration-tier variants V1-V3 branch before calibration and re-run it. Each non-central scenario changes exactly one knob from the central defaults. Bands are the minimum, S0 and the maximum of each gap table across every scenario, and each band bound names the scenario that set it.", + "status": "awaiting D1 (MC3a, MC8-MC12); S4's factor awaits Chronicle evidence", + "tiers": { + "entitlement": { + "branch_point": "nz.as" + }, + "calibration": { + "branch_point": "nz.open" + } + }, + "central_knobs": { + "rent_allocation": "reference_unit", + "asset_test": "on", + "area_column": "as_area", + "rent_stock_factor": 1.0, + "superannuitant_treatment": "exclude_and_count", + "aging_index": "central", + "beneficiary_assignment": "central" + }, + "knob_domains": { + "rent_allocation": [ + "reference_unit", + "per_adult_share" + ], + "asset_test": [ + "on", + "off" + ], + "area_column": [ + "as_area", + "as_area_alt" + ], + "rent_stock_factor": "a positive number; null only while status says it awaits evidence", + "superannuitant_treatment": [ + "exclude_and_count", + "reg17_superannuitant_base_rate" + ], + "aging_index": [ + "central", + "low", + "high" + ], + "beneficiary_assignment": [ + "central", + "variant" + ] + }, + "knob_method_card_rows": { + "rent_allocation": "MC8", + "asset_test": "MC10", + "area_column": "MC9", + "rent_stock_factor": "MC8", + "superannuitant_treatment": "MC11", + "aging_index": "MC5", + "beneficiary_assignment": "MC6" + }, + "scenarios": [ + { + "id": "S0", + "tier": "entitlement", + "method_card_row": "MC3", + "knobs": {}, + "description": "central defaults: the central value of every MC3 band" + }, + { + "id": "S1", + "tier": "entitlement", + "method_card_row": "MC10", + "knobs": { + "asset_test": "off" + }, + "description": "asset test off: the Accommodation Supplement cash-assets input is set to 0 (upper bound)", + "input_overrides": [ + { + "variable": "accommodation_supplement_cash_assets", + "value": 0 + } + ] + }, + { + "id": "S2", + "tier": "entitlement", + "method_card_row": "MC8", + "knobs": { + "rent_allocation": "per_adult_share" + }, + "description": "household rent split per adult share instead of allocated to the reference unit (upper sensitivity)" + }, + { + "id": "S3", + "tier": "entitlement", + "method_card_row": "MC9", + "knobs": { + "area_column": "as_area_alt" + }, + "description": "the alternative territorial authority to Accommodation Supplement area crosswalk (as_area_crosswalk.json alternative)" + }, + { + "id": "S4", + "tier": "entitlement", + "method_card_row": "MC8", + "knobs": { + "rent_stock_factor": null + }, + "description": "stock adjustment to MBIE lodged-bond rents", + "status": "awaiting Chronicle evidence on whether MBIE bond rents describe newly lodged bonds or the active stock; G8 writes the factor, or removes S4 if the rents are active stock" + }, + { + "id": "S5", + "tier": "entitlement", + "method_card_row": "MC11", + "knobs": { + "superannuitant_treatment": "reg17_superannuitant_base_rate" + }, + "description": "reg 17 superannuitant treatment, the alternative to the MC11 default. The override is the engine-calling bridge, which rewrites the base rate; a non-engine override would hand-compute policy (as_rate_bridge.json superannuitant_units)" + }, + { + "id": "V1", + "tier": "calibration", + "method_card_row": "MC5", + "knobs": { + "aging_index": "low" + }, + "description": "low aging index for the calibration references (MC5 ages IRD bands from TY2024 to TY2026-27 by a declared index)" + }, + { + "id": "V2", + "tier": "calibration", + "method_card_row": "MC5", + "knobs": { + "aging_index": "high" + }, + "description": "high aging index" + }, + { + "id": "V3", + "tier": "calibration", + "method_card_row": "MC6", + "knobs": { + "beneficiary_assignment": "variant" + }, + "description": "beneficiary assignment variant for the main-benefit and NZ Super receipt layer" + } + ], + "gap_conventions": { + "annualisation": { + "weeks_per_year": 52, + "method_card_row": "MC12", + "note": "the conformance harness uses 365/7 weeks (ops main run.py:168); the model states and uses 52" + }, + "groupings": { + "method_card_row": "MC3", + "tables": [ + "income_decile_by_family_type", + "beneficiary_superannuitant_other" + ] + }, + "comparators": { + "method_card_row": "MC2", + "references": "holdout_references.json, holdout_use accommodation_supplement_dollar_comparator" + } + }, + "keying_note": "Charter C2 (adding a scenario re-keys only the bands and package nodes) holds only if each scenario's graph nodes carry that scenario's own resolved knobs and a digest of its own entry, not this file's SHA-256." +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/source_stages.json b/packages/microcosm-build/src/microcosm/build/nz/source_stages.json new file mode 100644 index 0000000000..3618155c90 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/source_stages.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "country": "nz", + "policy": "New Zealand v0 transports the immutable public Microcosm US support artifact into a destination Frame. Donor records remain US support records, preserve their support-stratum provenance, and are never represented as observed New Zealand microdata. Only Axiom inputs declared for the rules bindings in axiom_rules_bindings.json may cross the rules boundary; missing substantive inputs fail closed and no country-specific Python runtime is permitted.", + "stages": [ + { + "stage": "load_populace_us_support_pool", + "survey": "Microcosm US Build P support population (US donor support records)", + "source": "https://huggingface.co/datasets/policyengine/populace-us", + "grain": "person", + "artifacts": [ + { + "kind": "public_microdata", + "format": "hdf5", + "vintage": "2024", + "locator": "populace_us_2024.h5", + "revision": "populace-us-2024-buildp-sparse-rmloss100-cae8640-20260728T011454Z", + "repository": { + "provider": "huggingface", + "owner": "policyengine", + "name_parts": [ + "populace-us" + ], + "repo_type": "dataset" + }, + "sha256": "48b9d479fb4fd1c3537f9383ce4697d130b6f618658409d74f6233c43b994c7e", + "size_bytes": 462915783, + "integrity_note": "The revision is the immutable release tag named by the public latest.json pointer on 2026-08-29. Execution must verify both SHA-256 and byte size before reading; main/latest fallback is forbidden." + } + ], + "operations": [ + { + "kind": "read_tables", + "tables": [ + "person", + "household" + ], + "weight": "household_weight" + } + ], + "outputs": [ + "person_id", + "household_id", + "person_household_id", + "household_weight", + "support_stratum", + "donor_country_code", + "age", + "sex" + ], + "nonnegative_outputs": [ + "household_weight", + "age" + ], + "notes": "This pins an authenticated donor artifact, not an executable NZ loader. The generic loader must preserve Frame entity ids, membership, typed household design weights, and support strata; set donor_country_code to US; refuse unreceipted aliases; and convert donor mass to a Chronicle-supplied NZ population total with a mass-conservation receipt. The typed operation vocabulary cannot represent that destination-mass bridge without inventing its still-missing target value, so execution remains blocked. In the graph build (docs/nz-take-up-engine-plan.md) the shared transport CREATE kernel performs this load, builds the benefit units of benefit_unit_rule.json, and installs design weights at the Stats NZ population scale." + } + ] +} diff --git a/packages/microcosm-build/src/microcosm/build/nz/spec/bundle.yaml b/packages/microcosm-build/src/microcosm/build/nz/spec/bundle.yaml new file mode 100644 index 0000000000..7a8f222d62 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/spec/bundle.yaml @@ -0,0 +1,16 @@ +country: nz +identity_generation: 1 +seed_protocol: legacy-v1 +dataset_run: + target_period: 2026 +status: >- + New Zealand public-donor transport package for tax year 2026-27, laid out + for the graph build described in docs/nz-take-up-engine-plan.md. The exact + Microcosm US Build P donor is pinned. The rules bindings name rulespec-nz + modules at a commit on its main branch. Execution still requires + cell-expanded Chronicle facts with a reviewed 2026 basis, the shared + transport, unit-construction and group-encode operators, harvested + territorial-authority to Accommodation Supplement area shares, and the + ex-ante calibration thresholds of the D1 method card. Every target + reference is an unactivated placeholder. No runtime or release may + substitute stubs. diff --git a/packages/microcosm-build/src/microcosm/build/nz/spec/catalogs.yaml b/packages/microcosm-build/src/microcosm/build/nz/spec/catalogs.yaml new file mode 100644 index 0000000000..df6234d6bc --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/spec/catalogs.yaml @@ -0,0 +1,121 @@ +columns: +- key: household.household_id + contract: + entity: household + dtype: int64 + unit: count + definition_period: eternity + vintage: vintage:nz_target_2026 + nullable: false + domain: frame_identity + public_stability: internal +- key: household.household_weight + contract: + entity: household + dtype: float64 + unit: count + definition_period: year + vintage: vintage:nz_target_2026 + nullable: false + domain: frame_weight + public_stability: internal +- key: family.family_id + contract: + entity: family + dtype: int64 + unit: count + definition_period: eternity + vintage: vintage:nz_target_2026 + nullable: false + domain: frame_identity + public_stability: internal +- key: family.family_household_id + contract: + entity: family + dtype: int64 + unit: count + definition_period: eternity + vintage: vintage:nz_target_2026 + nullable: false + domain: frame_membership + public_stability: internal +- key: person.person_id + contract: + entity: person + dtype: int64 + unit: count + definition_period: eternity + vintage: vintage:nz_target_2026 + nullable: false + domain: frame_identity + public_stability: internal +- key: person.person_household_id + contract: + entity: person + dtype: int64 + unit: count + definition_period: eternity + vintage: vintage:nz_target_2026 + nullable: false + domain: frame_membership + public_stability: internal +- key: person.person_family_id + contract: + entity: person + dtype: int64 + unit: count + definition_period: eternity + vintage: vintage:nz_target_2026 + nullable: false + domain: frame_membership + public_stability: internal +- key: person.support_stratum + contract: + entity: person + dtype: category + unit: categorical + definition_period: eternity + vintage: vintage:nz_support_pool_2024_buildp + nullable: false + domain: support_provenance + public_stability: internal +- key: person.donor_country_code + contract: + entity: person + dtype: category + unit: categorical + definition_period: eternity + vintage: vintage:nz_support_pool_2024_buildp + nullable: false + domain: support_provenance + public_stability: internal +- key: person.age + contract: + entity: person + dtype: int64 + unit: count + definition_period: year + vintage: vintage:nz_target_2026 + nullable: false + domain: donor_demography + public_stability: internal +- key: person.sex + contract: + entity: person + dtype: category + unit: categorical + definition_period: year + vintage: vintage:nz_target_2026 + nullable: false + domain: donor_demography + public_stability: internal +- key: person.region_code + contract: + entity: person + dtype: category + unit: categorical + definition_period: year + vintage: vintage:nz_region_2025 + nullable: false + domain: target_assigned_geography + public_stability: internal diff --git a/packages/microcosm-build/src/microcosm/build/nz/spec/geography.yaml b/packages/microcosm-build/src/microcosm/build/nz/spec/geography.yaml new file mode 100644 index 0000000000..9e75e6a5d9 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/spec/geography.yaml @@ -0,0 +1,33 @@ +phase: legacy +status: >- + Contract-only F0 expression of the national region layer. The complete 2025 + Stats NZ region roster and cell facts remain Chronicle inputs, and these + kernel ids are F0 contract vocabulary, never executable producers. In the + graph build each household draws one atomic territorial-authority by + Accommodation Supplement area unit from population-weighted support, and + region, territorial authority and Accommodation Supplement area derive from + it; this resource records only the region layer and its vintage. Region is + target-assigned on US donor records, never source-observed. +assignment: + anchor: region + order: before_gap_fill + kernels: + assign: kernel:assign_nz_regions + validate: kernel:nz_region_geography_gate + draw: + region: + universe: region_nationally + weight: stats_nz_population + derive: + - region_code + assertions: + - assigned_region_complete + - geography_vintage_exact + ladder_source: source:nz_region_assignment_contract + seed: stream:geography_legacy + default_seed: 0 + layer_vintages: + region: vintage:nz_region_2025 + validation: + - region_assignment_support + - vintage_refusal diff --git a/packages/microcosm-build/src/microcosm/build/nz/spec/sources.yaml b/packages/microcosm-build/src/microcosm/build/nz/spec/sources.yaml new file mode 100644 index 0000000000..f58c65a5fa --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/spec/sources.yaml @@ -0,0 +1,70 @@ +sources: +- byte_size: 2864 + id: nz_populace_us_support_contract + loader: kernel:load_populace_us_support_pool + role: nz_populace_us_support_pool + sha256: a4e5bb6801e8f2b8bf539dcb739aaa35c4d2162909d61f18327932e7e252aae7 + vintage_authorities: + - id: nz_support_pool_2024_buildp + kind: survey_period + value: '2024' + vintages: + - vintage:nz_support_pool_2024_buildp +- byte_size: 2058 + id: nz_region_assignment_contract + loader: kernel:assign_nz_regions + role: nz_region_assignment_contract + sha256: 353821844d85d2622c328b4986dd9e3a8075c13498b9f56d349c4578edf7495a + vintage_authorities: + - id: nz_region_2025 + kind: geography_vintage + value: 2025_region + - id: nz_region_roster_2025 + kind: geography_vintage + value: 2025_region_roster + vintages: + - vintage:nz_region_2025 + - vintage:nz_region_roster_2025 +stage_manifest: + country: nz + policy: New Zealand v0 transports the immutable public Microcosm US support artifact into a destination Frame. Donor records remain US support records, preserve their support-stratum provenance, and are never represented as observed New Zealand microdata. Only Axiom inputs declared for the rules bindings in axiom_rules_bindings.json may cross the rules boundary; missing substantive inputs fail closed and no country-specific Python runtime is permitted. + version: 1 +stages: +- artifacts: + - format: hdf5 + integrity_note: The revision is the immutable release tag named by the public latest.json pointer on 2026-08-29. Execution must verify both SHA-256 and byte size before reading; main/latest fallback is forbidden. + kind: public_microdata + locator: populace_us_2024.h5 + repository: + name_parts: + - populace-us + owner: policyengine + provider: huggingface + repo_type: dataset + revision: populace-us-2024-buildp-sparse-rmloss100-cae8640-20260728T011454Z + sha256: 48b9d479fb4fd1c3537f9383ce4697d130b6f618658409d74f6233c43b994c7e + size_bytes: 462915783 + vintage: '2024' + grain: person + nonnegative_outputs: + - household_weight + - age + notes: This pins an authenticated donor artifact, not an executable NZ loader. The generic loader must preserve Frame entity ids, membership, typed household design weights, and support strata; set donor_country_code to US; refuse unreceipted aliases; and convert donor mass to a Chronicle-supplied NZ population total with a mass-conservation receipt. The typed operation vocabulary cannot represent that destination-mass bridge without inventing its still-missing target value, so execution remains blocked. In the graph build (docs/nz-take-up-engine-plan.md) the shared transport CREATE kernel performs this load, builds the benefit units of benefit_unit_rule.json, and installs design weights at the Stats NZ population scale. + operations: + - kind: read_tables + tables: + - person + - household + weight: household_weight + outputs: + - person_id + - household_id + - person_household_id + - household_weight + - support_stratum + - donor_country_code + - age + - sex + source: https://huggingface.co/datasets/policyengine/populace-us + stage: load_populace_us_support_pool + survey: Microcosm US Build P support population (US donor support records) diff --git a/packages/microcosm-build/src/microcosm/build/nz/spec/spine.yaml b/packages/microcosm-build/src/microcosm/build/nz/spec/spine.yaml new file mode 100644 index 0000000000..9b3fca58be --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/spec/spine.yaml @@ -0,0 +1,10 @@ +channels: +- id: populace_us_support + source: nz_populace_us_support_contract + observed_geography: country +assembly: + mass_anchor_channel: populace_us_support + shared_dtype_policy: canonical_string_storage +support_roles: +- id: populace_us_donor_base + kind: none diff --git a/packages/microcosm-build/src/microcosm/build/nz/spec/vintages.yaml b/packages/microcosm-build/src/microcosm/build/nz/spec/vintages.yaml new file mode 100644 index 0000000000..5c539e8d34 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/spec/vintages.yaml @@ -0,0 +1,32 @@ +records: +- id: nz_support_pool_2024_buildp + kind: survey_period_ref + authority_ref: + kind: source_record + source: source:nz_populace_us_support_contract + authority: nz_support_pool_2024_buildp + compatible_with: + - vintage:nz_target_2026 +- id: nz_target_2026 + kind: target_period_ref + authority_ref: + kind: dataset_run + pointer: /dataset_run/target_period + compatible_with: + - vintage:nz_support_pool_2024_buildp +- id: nz_region_2025 + kind: geography_vintage_ref + authority_ref: + kind: source_record + source: source:nz_region_assignment_contract + authority: nz_region_2025 + compatible_with: + - vintage:nz_region_roster_2025 +- id: nz_region_roster_2025 + kind: geography_vintage_ref + authority_ref: + kind: source_record + source: source:nz_region_assignment_contract + authority: nz_region_roster_2025 + compatible_with: + - vintage:nz_region_2025 diff --git a/packages/microcosm-build/src/microcosm/build/nz/target_references.json b/packages/microcosm-build/src/microcosm/build/nz/target_references.json new file mode 100644 index 0000000000..b3cb6995dc --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/nz/target_references.json @@ -0,0 +1,195 @@ +{ + "country": "nz", + "reference_set": "calibration", + "description": "New Zealand v0 weight-calibration references: the D1 method-card MC5 list (region by age band, household composition, census family type, tenure by region, IRD taxable-income bands, MSD main-benefit recipients, NZ Super and Veterans Pension recipients). Observed values and source lineage live only in Chronicle. Every row is an unactivated placeholder until Chronicle supplies cell-pinned facts and a reviewed Frame column and period bridge, so compilation refuses it. Pre-calibration inputs live in precal_references.json and held-out comparators in holdout_references.json; the three files are separate resources so that editing one set cannot change the resource digest another set's graph nodes carry.", + "allowed_value_operations": [ + "identity" + ], + "target_references": [ + { + "name": "stats_nz_population_by_age_sex_region", + "ledger_selector": { + "source_name": "stats_nz_population_estimates", + "source_measure_id": "estimated_resident_population_by_age_sex_region", + "geography_level": "region" + }, + "entity": "person", + "measure": "people", + "period": 2025, + "family": "demography", + "metadata": { + "activation_status": "requires_harvested_cell_references_and_2026_basis", + "criticality": "release_blocking", + "geography_vintage": "2025", + "measure_kind": "prepared_column", + "publisher": "Stats NZ", + "target_role": "calibration", + "reference_set": "calibration", + "method_card_row": "MC5" + }, + "notes": "Primary age-band x sex x region population surface. Chronicle must supply one dimension- and region-pinned fact per selected cell, the complete region roster, uncertainty, and a reviewed bridge from the 30 June 2025 reference date to the 2026-27 build basis." + }, + { + "name": "stats_nz_census_households_by_composition", + "ledger_selector": { + "source_name": "stats_nz_census_2023_households", + "source_measure_id": "households_by_composition", + "geography_level": "country" + }, + "entity": "household", + "measure": "households", + "period": 2023, + "family": "household_structure", + "metadata": { + "activation_status": "requires_harvested_cell_references_and_2026_basis", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Stats NZ", + "target_role": "calibration", + "reference_set": "calibration", + "method_card_row": "MC5" + }, + "notes": "Household and family composition is the structural control for WFF family assembly. Chronicle must expand the published categories and supply a reviewed 2023-to-2026 basis before activation." + }, + { + "name": "stats_nz_census_families_by_type", + "ledger_selector": { + "source_name": "stats_nz_census_2023_families", + "source_measure_id": "families_by_family_type", + "geography_level": "country" + }, + "entity": "household", + "measure": "census_families", + "period": 2023, + "family": "household_structure", + "metadata": { + "activation_status": "requires_harvested_cell_references_and_2026_basis", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Stats NZ", + "target_role": "calibration", + "reference_set": "calibration", + "method_card_row": "MC5" + }, + "notes": "Census 2023 family type (couple without children, couple with children, one parent with children), the MC5 family-type margin. Census families are not the MC7 benefit units: the reviewed Frame column counts census families per household, emitted as household columns with no second group entity. Chronicle must expand the published categories and supply a reviewed 2023-to-2026 basis before activation.", + "filter": "census_family_type_cell_indicator" + }, + { + "name": "stats_nz_census_households_by_tenure_region", + "ledger_selector": { + "source_name": "stats_nz_census_2023_households", + "source_measure_id": "households_by_tenure_region", + "geography_level": "region" + }, + "entity": "household", + "measure": "households", + "filter": "housing_tenure_cell_indicator", + "period": 2023, + "family": "housing_tenure", + "metadata": { + "activation_status": "requires_harvested_cell_references_and_prepared_indicators", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Stats NZ", + "target_role": "calibration", + "reference_set": "calibration", + "method_card_row": "MC5" + }, + "notes": "Each tenure x region cell needs its own deterministic indicator binding. Market-rent amounts are validation/input evidence for Accommodation Supplement, not household-count calibration rows." + }, + { + "name": "ird_taxable_income_people_by_band", + "ledger_selector": { + "source_name": "ird_taxable_income_distribution", + "source_measure_id": "individuals_by_taxable_income_band", + "geography_level": "country" + }, + "entity": "person", + "measure": "people", + "filter": "taxable_income_band_indicator", + "period": 2024, + "family": "income", + "metadata": { + "activation_status": "requires_harvested_cell_references_and_2026_basis", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Inland Revenue", + "target_role": "calibration", + "reference_set": "calibration", + "method_card_row": "MC5" + }, + "notes": "Administrative count backbone. Chronicle must emit one income-band fact and reviewed prepared indicator per cell; the target basis must be advanced explicitly to the build period." + }, + { + "name": "ird_taxable_income_total_by_band", + "ledger_selector": { + "source_name": "ird_taxable_income_distribution", + "source_measure_id": "total_taxable_income_by_band_nzd", + "geography_level": "country" + }, + "entity": "person", + "measure": "taxable_income_nzd", + "filter": "taxable_income_band_indicator", + "period": 2024, + "family": "income", + "metadata": { + "activation_status": "requires_harvested_cell_references_candidate_column_and_2026_basis", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Inland Revenue", + "target_role": "calibration", + "reference_set": "calibration", + "method_card_row": "MC5" + }, + "notes": "Dollar companion to the count bands. Activation requires a destination-NZD taxable-income column with a reviewed donor bridge; US-dollar values may never be compared directly with NZD thresholds." + }, + { + "name": "msd_main_benefit_recipients_by_program_age", + "ledger_selector": { + "source_name": "msd_quarterly_benefit_facts", + "source_measure_id": "main_benefit_recipients_by_program_age", + "geography_level": "country" + }, + "entity": "person", + "measure": "receives_main_benefit", + "period": "2026-03", + "family": "main_benefits", + "metadata": { + "activation_status": "requires_harvested_cell_references_and_take_up_assignment", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Ministry of Social Development", + "target_role": "calibration", + "reference_set": "calibration", + "method_card_row": "MC5" + }, + "notes": "Count-only administrative cells constrain an explicit receipt/take-up layer. They must never be bound to Axiom full-entitlement outputs or used to force entitlement dollars to equal actual expenditure." + }, + { + "name": "msd_nz_super_veterans_pension_recipients", + "ledger_selector": { + "source_name": "msd_nz_super_veterans_pension_facts", + "source_measure_id": "nz_super_veterans_pension_recipients", + "geography_level": "country" + }, + "entity": "person", + "measure": "receives_nz_super_or_veterans_pension", + "period": "2025-06", + "family": "superannuation", + "metadata": { + "activation_status": "requires_harvested_fact_reference_and_take_up_assignment", + "criticality": "release_blocking", + "measure_kind": "prepared_column", + "publisher": "Ministry of Social Development", + "target_role": "calibration", + "reference_set": "calibration", + "method_card_row": "MC5" + }, + "notes": "Recipient stock is a receipt-state target, not an entitlement-output target. Expenditure is validation evidence and stays outside the calibration manifest." + } + ], + "target_profile": { + "schema_version": 1, + "hierarchy_reconciliations": [] + } +} diff --git a/packages/microcosm-build/src/microcosm/build/spec_engine/resolver.py b/packages/microcosm-build/src/microcosm/build/spec_engine/resolver.py index ded09d682a..5570990538 100644 --- a/packages/microcosm-build/src/microcosm/build/spec_engine/resolver.py +++ b/packages/microcosm-build/src/microcosm/build/spec_engine/resolver.py @@ -150,16 +150,18 @@ def contract_only_ids(self) -> frozenset[str]: } ) -# UK, BE, and AM use these reviewed ids to prove that their shared-core bundle -# shapes resolve through the same compiler. F0 does not yet bind them into the -# generic executor: the Belgian and Armenian implementations are absent, and -# the existing UK functions have no compiler-owned producer binding or -# per-kernel implementation attestation. They therefore close references but -# must never be presented as executable producer kernels. +# UK, BE, AM, and NZ use these reviewed ids to prove that their shared-core +# bundle shapes resolve through the same compiler. F0 does not yet bind them +# into the generic executor: the Belgian, Armenian, and New Zealand +# implementations are absent, and the existing UK functions have no +# compiler-owned producer binding or per-kernel implementation attestation. +# They therefore close references but must never be presented as executable +# producer kernels. F0_CONTRACT_ONLY_KERNEL_IDS = frozenset( { "am_community_geography_gate", "assign_am_marz", + "assign_nz_regions", "assign_uk_geography_ladder", "be_commune_geography_gate", # microcosm#901: the UK spec projects the raw FRS tables through the @@ -171,6 +173,7 @@ def contract_only_ids(self) -> frozenset[str]: "load_populace_us_support_pool", "load_uk_atomic_area_support", "load_uk_national_frame", + "nz_region_geography_gate", "silc_load", "uk_geography_ladder_gate", } diff --git a/packages/microcosm-build/tests/engine_free/shared/test_country_spec.py b/packages/microcosm-build/tests/engine_free/shared/test_country_spec.py index d0206f004b..90668886f4 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_country_spec.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_country_spec.py @@ -33,7 +33,7 @@ COUNTRY_PACKAGE_ROOT = _TEST_PATHS.package / "src/microcosm/build" GOLDEN_ROOT = _TEST_PATHS.tests / "golden" -GOLDEN_COUNTRIES = ("am", "be") +GOLDEN_COUNTRIES = ("am", "be", "nz") FORBIDDEN_TARGET_VALUE_KEYS = {"value", "values", "observed", "observed_value"} diff --git a/packages/microcosm-build/tests/engine_free/shared/test_nz_spec_package.py b/packages/microcosm-build/tests/engine_free/shared/test_nz_spec_package.py new file mode 100644 index 0000000000..f047b894a5 --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/shared/test_nz_spec_package.py @@ -0,0 +1,859 @@ +"""The New Zealand spec package, graph era: invariants over its resources. + +The NZ package is spec data only. These tests state the invariants the graph +build relies on and check them on the committed resources; each validator is +also exercised by Hypothesis so a vacuous pass on today's data cannot hide a +broken check: + +- the three reference sets (calibration, pre-calibration, hold-out) are + separate resources, every row is an unactivated placeholder, and no + hold-out fact can reach calibration; +- no resource names a rulespec-nz commit other than the one pin, which is on + rulespec-nz main; +- every crosswalk territorial authority's area shares sum to 1 within 1e-9; +- every scenario names exactly one method-card row and a tier from + {entitlement, calibration}, and changes exactly one knob from the centre. + +Differential checks compare two records of one fact: the loader's target +references with a direct parse, the rules bindings' module digests with the +committed Axiom input surface, and the export contract's formula-owned list +with the bound variables. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from collections.abc import Iterator, Mapping +from typing import Any + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from microcosm.build.country_spec import load_country_spec +from microcosm.build.ledger_targets import ( + LedgerTargetReference, + compile_ledger_target_references, +) +from microcosm.build.spec_engine import load_yaml12 +from test_support.paths import paths_for + +_BUILD = paths_for("microcosm-build") +NZ_ROOT = _BUILD.package / "src/microcosm/build/nz" +NZ_INPUT_SURFACE = ( + paths_for("microcosm-frame").tests / "fixtures/axiom_input_surfaces/nz.json" +) + +REFERENCE_FILES = { + "calibration": "target_references.json", + "precal": "precal_references.json", + "holdout": "holdout_references.json", +} +HELD_OUT_FAMILIES = {"accommodation_supplement", "working_for_families"} +FORBIDDEN_VALUE_KEYS = {"value", "values", "observed", "observed_value"} +#: The head of rulespec-nz#117, the unmerged Budget-transport draft that +#: microcosm#821's WFF stage pinned. It must never re-enter the package. +UNMERGED_RULESPEC_COMMIT = "3b663b3e6eb6408351154990be0c4b92d42c92da" +METHOD_CARD_ROW = re.compile(r"MC(?:[1-9]|1[0-5])") +TIERS = {"entitlement", "calibration"} +SHARE_TOLERANCE = 1e-9 +AREAS = {1, 2, 3, 4} + + +def _load(name: str) -> dict[str, Any]: + return json.loads((NZ_ROOT / name).read_text(encoding="utf-8")) + + +def _rows(reference_set: str) -> list[dict[str, Any]]: + return _load(REFERENCE_FILES[reference_set])["target_references"] + + +def _references(reference_set: str) -> list[LedgerTargetReference]: + return [LedgerTargetReference(**row) for row in _rows(reference_set)] + + +def _nested_keys(value: object) -> Iterator[str]: + if isinstance(value, Mapping): + for key, child in value.items(): + yield key + yield from _nested_keys(child) + elif isinstance(value, list): + for child in value: + yield from _nested_keys(child) + + +def _package_payloads() -> dict[str, object]: + payloads: dict[str, object] = {} + for path in sorted(NZ_ROOT.rglob("*")): + if not path.is_file(): + continue + name = path.relative_to(NZ_ROOT).as_posix() + text = path.read_text(encoding="utf-8") + payloads[name] = ( + json.loads(text) + if path.suffix == ".json" + else load_yaml12(text, source=name) + ) + return payloads + + +def _bindings() -> dict[str, dict[str, Any]]: + return {row["id"]: row for row in _load("axiom_rules_bindings.json")["bindings"]} + + +def _input_surface() -> dict[str, dict[str, Any]]: + surface = json.loads(NZ_INPUT_SURFACE.read_text(encoding="utf-8")) + return {module["path"]: module for module in surface["modules"]} + + +# --------------------------------------------------------------------------- +# Validators (exercised by Hypothesis below, applied to the package here) +# --------------------------------------------------------------------------- + +_HEX = re.compile(r"(?", "rulespec-nz ", "rulespec-nz commit ``" and +#: similar prose: the repository name, then at most one connecting word. +_PROSE_MENTION = re.compile( + r"rulespec-nz(?:@|\W+(?:(?:at|commit|pin|head)\W+)?)`?([0-9a-fA-F]{7,40})(?![0-9a-fA-F])" +) + + +def rulespec_commit_mentions(value: object) -> set[str]: + """Return every commit (7-40 hex) a payload attributes to rulespec-nz. + + A commit is attributed when it follows the repository name in prose + (``rulespec-nz@``, ``rulespec-nz commit ``, ``rulespec-nz + ``), or when it is a ``commit``/``rulespec_commit`` value of a + mapping whose ``repository`` names rulespec-nz or that sits under a + ``rulespec`` key. + """ + + found: set[str] = set() + + def visit(node: object, *, rulespec_context: bool) -> None: + if isinstance(node, Mapping): + repository = str(node.get("repository", "")) + context = rulespec_context or repository.endswith("rulespec-nz") + for key, child in node.items(): + if key in {"commit", "rulespec_commit"} and isinstance(child, str): + if context or key == "rulespec_commit": + found.update(_HEX.findall(child.lower())) + visit(child, rulespec_context=context or key == "rulespec") + elif isinstance(node, list): + for child in node: + visit(child, rulespec_context=rulespec_context) + elif isinstance(node, str): + found.update(match.lower() for match in _PROSE_MENTION.findall(node)) + + visit(value, rulespec_context=False) + return found + + +def reviewed_rulespec_commits() -> set[str]: + """Full rulespec-nz commits reviewed as on its main branch. + + The rules-binding pin (verified with ``git branch -r --contains`` when it + was set) and the commit the committed Axiom input surface was generated + at (an ancestor of the pin). + """ + + pin = _load("axiom_rules_bindings.json")["rulespec"]["commit"] + surface = json.loads(NZ_INPUT_SURFACE.read_text(encoding="utf-8")) + return {pin, surface["rulespec"]["commit"]} + + +def unreviewed_mentions(mentions: set[str], reviewed: set[str]) -> set[str]: + """Mentions that do not abbreviate a reviewed commit.""" + + return { + mention + for mention in mentions + if not any(commit.startswith(mention) for commit in reviewed) + } + + +def crosswalk_errors(rows: list[Mapping[str, Any]]) -> list[str]: + """Name every crosswalk row that breaks the share contract.""" + + errors: list[str] = [] + seen: set[str] = set() + for row in rows: + code = str(row.get("ta_code", "")) + if not code: + errors.append("row without ta_code") + continue + if code in seen: + errors.append(f"{code}: duplicate territorial authority") + seen.add(code) + shares = row.get("shares") + if not isinstance(shares, list) or not shares: + errors.append(f"{code}: no shares") + continue + areas = [share.get("as_area") for share in shares] + if any(isinstance(area, bool) or not isinstance(area, int) for area in areas): + errors.append(f"{code}: area must be an integer") + continue + if len(set(areas)) != len(areas): + errors.append(f"{code}: repeated area") + if not set(areas) <= AREAS: + errors.append(f"{code}: area outside 1-4") + values = [share.get("population_share") for share in shares] + if not all( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and 0 <= value <= 1 + for value in values + ): + errors.append(f"{code}: share outside [0, 1]") + continue + if abs(math.fsum(values) - 1.0) > SHARE_TOLERANCE: + errors.append(f"{code}: shares sum to {math.fsum(values)!r}, not 1") + return errors + + +def modal_area(shares: list[Mapping[str, Any]]) -> int: + """The S3 alternative: the area with the largest share; ties refuse.""" + + best = max(share["population_share"] for share in shares) + winners = [ + share["as_area"] for share in shares if share["population_share"] == best + ] + if len(winners) != 1: + raise ValueError(f"tied modal areas {sorted(winners)}") + return int(winners[0]) + + +def scenario_errors(document: Mapping[str, Any]) -> list[str]: + """Name every scenario that breaks the declared-grid contract.""" + + errors: list[str] = [] + central = document["central_knobs"] + knob_rows = document["knob_method_card_rows"] + domains = document["knob_domains"] + ids = [scenario.get("id") for scenario in document["scenarios"]] + if len(set(ids)) != len(ids): + errors.append("duplicate scenario ids") + for scenario in document["scenarios"]: + label = scenario.get("id") + row = scenario.get("method_card_row") + if not isinstance(row, str) or not METHOD_CARD_ROW.fullmatch(row): + errors.append(f"{label}: must name exactly one method-card row") + if scenario.get("tier") not in TIERS: + errors.append(f"{label}: tier outside {sorted(TIERS)}") + knobs = scenario.get("knobs") + if not isinstance(knobs, Mapping): + errors.append(f"{label}: knobs must be a mapping") + continue + if not knobs: + if row != document["method_card_row"]: + errors.append(f"{label}: the central scenario names the grid row") + continue + if len(knobs) != 1: + errors.append(f"{label}: must change exactly one knob") + continue + ((knob, value),) = knobs.items() + if knob not in central: + errors.append(f"{label}: unknown knob {knob!r}") + continue + if value == central[knob]: + errors.append(f"{label}: knob {knob!r} equals the central value") + if knob_rows.get(knob) != row: + errors.append(f"{label}: knob {knob!r} belongs to {knob_rows.get(knob)}") + domain = domains[knob] + if isinstance(domain, list): + if value not in domain: + errors.append(f"{label}: {value!r} outside the {knob!r} domain") + elif value is None: + if "status" not in scenario: + errors.append(f"{label}: a pending value needs a status") + elif not (isinstance(value, (int, float)) and value > 0): + errors.append(f"{label}: {knob!r} needs a positive number") + return errors + + +# --------------------------------------------------------------------------- +# Reference sets +# --------------------------------------------------------------------------- + + +class TestReferenceSets: + @pytest.mark.parametrize("reference_set", sorted(REFERENCE_FILES)) + def test_each_set_is_a_self_identifying_nz_reference_resource( + self, reference_set: str + ) -> None: + document = _load(REFERENCE_FILES[reference_set]) + assert document["country"] == "nz" + assert document["reference_set"] == reference_set + assert document["allowed_value_operations"] == ["identity"] + rows = document["target_references"] + assert rows + assert FORBIDDEN_VALUE_KEYS.isdisjoint(_nested_keys(rows)) + names = [row["name"] for row in rows] + assert len(set(names)) == len(names) + assert {row["metadata"]["reference_set"] for row in rows} == {reference_set} + for row in rows: + assert METHOD_CARD_ROW.fullmatch(row["metadata"]["method_card_row"]) + + @pytest.mark.parametrize("reference_set", sorted(REFERENCE_FILES)) + def test_every_reference_is_a_placeholder_the_compiler_refuses( + self, reference_set: str + ) -> None: + references = _references(reference_set) + for reference in references: + status = reference.metadata["activation_status"] + assert status.startswith("requires_") + with pytest.raises(ValueError, match="non-executable placeholder"): + compile_ledger_target_references([], [reference], country="nz") + + def test_pre_calibration_rows_are_not_weight_calibration_targets(self) -> None: + roles = { + reference.metadata["target_role"] for reference in _references("precal") + } + assert roles == {"pre_calibration"} + + def test_loader_and_direct_parse_agree_on_the_calibration_set(self) -> None: + # Differential: the country-spec loader validates target_references.json; + # the pre-calibration and hold-out files use the direct parse. + loaded = load_country_spec("nz").target_references + assert loaded == tuple(_references("calibration")) + + def test_calibration_set_is_the_method_card_mc5_list(self) -> None: + references = _references("calibration") + assert [reference.name for reference in references] == [ + "stats_nz_population_by_age_sex_region", + "stats_nz_census_households_by_composition", + "stats_nz_census_families_by_type", + "stats_nz_census_households_by_tenure_region", + "ird_taxable_income_people_by_band", + "ird_taxable_income_total_by_band", + "msd_main_benefit_recipients_by_program_age", + "msd_nz_super_veterans_pension_recipients", + ] + assert {reference.metadata["target_role"] for reference in references} == { + "calibration" + } + assert {reference.metadata["method_card_row"] for reference in references} == { + "MC5" + } + + def test_holdout_set_is_validation_only(self) -> None: + references = _references("holdout") + assert {reference.name for reference in references} == { + "msd_accommodation_supplement_recipients_by_region", + "msd_annual_report_accommodation_assistance_expenditure", + "treasury_an24_01_accommodation_supplement_fiscal_total", + "ird_wff_recipient_families", + "ird_iwtc_recipient_families", + "ird_wff_recipient_families_by_family_income_band", + } + for reference in references: + assert reference.metadata["target_role"] == "validation" + assert reference.metadata["criticality"] == "diagnostic" + assert reference.family in HELD_OUT_FAMILIES + + def test_no_held_out_fact_reaches_calibration_or_pre_calibration(self) -> None: + def selectors(reference_set: str) -> set[tuple[str, str]]: + return { + ( + str(reference.ledger_selector["source_name"]), + str(reference.ledger_selector["source_measure_id"]), + ) + for reference in _references(reference_set) + } + + held = _references("holdout") + upstream = _references("calibration") + _references("precal") + assert {reference.name for reference in held}.isdisjoint( + reference.name for reference in upstream + ) + assert selectors("holdout").isdisjoint( + selectors("calibration") | selectors("precal") + ) + assert HELD_OUT_FAMILIES.isdisjoint(reference.family for reference in upstream) + + def test_a_reference_in_two_upstream_sets_names_one_fact(self) -> None: + calibration = {row["name"]: row for row in _rows("calibration")} + shared = [row for row in _rows("precal") if row["name"] in calibration] + assert {row["name"] for row in shared} == { + "msd_main_benefit_recipients_by_program_age", + "msd_nz_super_veterans_pension_recipients", + } + fact_fields = ( + "ledger_selector", + "entity", + "measure", + "filter", + "period", + "family", + ) + for row in shared: + twin = calibration[row["name"]] + assert {field: row.get(field) for field in fact_fields} == { + field: twin.get(field) for field in fact_fields + } + + def test_gate_profile_requires_exactly_the_calibration_families(self) -> None: + spec = load_country_spec("nz") + gate = next( + gate for gate in spec.gates.gates if gate.gate == "target_profile_coverage" + ) + required = set(gate.parameters["required_families"]) + assert required == { + reference.family for reference in _references("calibration") + } + assert HELD_OUT_FAMILIES.isdisjoint(required) + + +# --------------------------------------------------------------------------- +# Rulespec pin +# --------------------------------------------------------------------------- + + +_HEX40_TEXT = st.text(alphabet="0123456789abcdef", min_size=40, max_size=40) +_FILLER = st.text(alphabet=st.characters(blacklist_categories=("Cs",)), max_size=20) +#: A suffix that cannot extend the planted hex run (41 hex digits is no commit). +_SUFFIX = _FILLER.filter(lambda text: text[:1].lower() not in set("0123456789abcdef")) + + +class TestRulespecPin: + def test_every_rulespec_commit_named_anywhere_was_reviewed_on_main(self) -> None: + pin = _load("axiom_rules_bindings.json")["rulespec"] + mentions = rulespec_commit_mentions(_package_payloads()) + assert pin["commit"] in mentions + assert unreviewed_mentions(mentions, reviewed_rulespec_commits()) == set() + assert pin["branch"] == "main" + # The branch claim is the lane's git receipt, not checkable offline. + assert "git branch -r --contains" in pin["verification"] + + def test_the_unmerged_budget_transport_commit_is_absent(self) -> None: + texts = [ + path.read_text(encoding="utf-8") + for path in NZ_ROOT.rglob("*") + if path.is_file() + ] + assert not any(UNMERGED_RULESPEC_COMMIT[:7] in text for text in texts) + + @settings(max_examples=200, deadline=None) + @given(commit=_HEX40_TEXT, prefix=_FILLER, suffix=_SUFFIX, nest=st.integers(0, 3)) + def test_scanner_finds_an_attributed_commit_at_any_depth( + self, commit: str, prefix: str, suffix: str, nest: int + ) -> None: + planted: object = f"{prefix}rulespec-nz@{commit}{suffix}" + for depth in range(nest): + planted = {"level": depth, "note": [planted]} + assert commit in rulespec_commit_mentions(planted) + record = {"repository": "TheAxiomFoundation/rulespec-nz", "commit": commit} + assert rulespec_commit_mentions({"wrapper": [record]}) == {commit} + + @settings(max_examples=200, deadline=None) + @given(commit=_HEX40_TEXT) + def test_scanner_ignores_commits_of_other_repositories(self, commit: str) -> None: + other = {"repository": "TheAxiomFoundation/axiom-oracles", "commit": commit} + assert rulespec_commit_mentions(other) == set() + + @settings(max_examples=200, deadline=None) + @given( + commit=_HEX40_TEXT, + length=st.integers(7, 40), + style=st.sampled_from( + [ + "pinned to rulespec-nz commit {}.", + "at rulespec-nz commit `{}`", + "generated at rulespec-nz {} today", + "rulespec-nz@{}:nz/statutes", + ] + ), + ) + def test_scanner_catches_abbreviated_prose_and_flags_unreviewed_commits( + self, commit: str, length: int, style: str + ) -> None: + short = commit[:length] + mentions = rulespec_commit_mentions({"notes": style.format(short)}) + assert short in mentions + reviewed = reviewed_rulespec_commits() + if not any(full.startswith(short) for full in reviewed): + assert unreviewed_mentions(mentions, reviewed) == {short} + + def test_scanner_catches_the_821_wording(self) -> None: + wording = "the rules boundary names the contract at rulespec-nz commit 3b663b3." + mentions = rulespec_commit_mentions({"status": wording}) + assert unreviewed_mentions(mentions, reviewed_rulespec_commits()) == {"3b663b3"} + + +# --------------------------------------------------------------------------- +# Rules bindings and the reg 17/18 bridge +# --------------------------------------------------------------------------- + + +class TestRulesBindings: + def test_bound_module_digests_match_the_committed_input_surface(self) -> None: + # Differential: two independent records of the same module bytes. + surface = _input_surface() + for binding in _bindings().values(): + module = surface[binding["rulespec_path"]] + assert module["status"] == "compiled" + assert module["sha256"] == binding["sha256"] + assert len(module["canonical_inputs"]) == binding["root_input_count"] + + def test_requested_variables_are_outputs_not_root_inputs(self) -> None: + surface = _input_surface() + for binding in _bindings().values(): + inputs = set(surface[binding["rulespec_path"]]["canonical_inputs"]) + variables = binding["variables"] + assert variables and len(set(variables)) == len(variables) + assert inputs.isdisjoint(variables) + + def test_bindings_declare_relation_free_modules_on_mapped_entities(self) -> None: + document = _load("axiom_rules_bindings.json") + entity_names = document["entity_names"] + assert set(document["periods"]) == {"2026-27"} + for binding in document["bindings"]: + assert binding["relations_declared"] is False + assert binding["imports_declared"] is False + assert entity_names[binding["entity"]] == binding["engine_entity"] + assert binding["period"] in document["periods"] + assert binding["rulespec_path"].startswith("nz/") + assert "nz.scn." not in json.dumps(document) + period = document["periods"]["2026-27"] + assert (period["kind"], period["start"], period["end"]) == ( + "tax_year", + "2026-04-01", + "2027-03-31", + ) + + def test_export_contract_excludes_exactly_the_bound_outputs(self) -> None: + # Differential: the export gate's formula-owned list and the bindings. + contract = _load("export_contract.json") + bound = {v for binding in _bindings().values() for v in binding["variables"]} + assert set(contract["formula_owned_excluded"]) == bound + assert contract["closed"] is True + assert set(contract["forbidden"]) == {"person_weight", "family_weight"} + assert ( + contract["_period"]["label"] + in _load("axiom_rules_bindings.json")["periods"] + ) + + def test_bridge_evaluates_only_bound_variables_under_real_input_names(self) -> None: + bridge = _load("as_rate_bridge.json") + bindings = _bindings() + surface = _input_surface() + assert set(bridge["rate_bindings"]) <= set(bindings) + evaluations = [] + for output in bridge["outputs"]: + evaluations += output.get("terms", []) + for branch in output.get("branches", []): + evaluations += branch["evaluations"] + assert evaluations + for evaluation in evaluations: + binding = bindings[evaluation["binding"]] + assert evaluation["binding"] in bridge["rate_bindings"] + assert evaluation["variable"] in binding["variables"] + assert evaluation["engine_entity"] == binding["engine_entity"] + inputs = set(surface[binding["rulespec_path"]]["canonical_inputs"]) + overrides = [row["variable"] for row in evaluation["input_overrides"]] + assert len(set(overrides)) == len(overrides) + assert set(overrides) <= inputs + + def test_bridge_targets_are_the_two_as_inputs_the_module_leaves_open(self) -> None: + bridge = _load("as_rate_bridge.json") + surface = _input_surface() + as_inputs = set( + surface[_bindings()["accommodation_supplement"]["rulespec_path"]][ + "canonical_inputs" + ] + ) + targets = {output["target_input"] for output in bridge["outputs"]} + assert targets == { + "accommodation_supplement_base_rate_weekly_amount", + "accommodation_supplement_non_beneficiary_income_cutout_weekly_amount", + } + assert targets <= as_inputs + (case,) = bridge["differential_expectations"]["cases"] + assert case["request_id"] == "golden-08" + assert set(case) >= targets + + def test_bridge_reads_only_unit_attributes_the_unit_rule_declares(self) -> None: + declared = set(_load("benefit_unit_rule.json")["unit_attributes"]["columns"]) + bridge_text = json.dumps(_load("as_rate_bridge.json")) + used = { + f"family.{name}" for name in re.findall(r"family\.([a-z_]+)", bridge_text) + } + assert used + assert used <= declared + + def test_reg17_family_tax_credit_term_uses_the_statutory_divisor(self) -> None: + (base_rate,) = [ + output + for output in _load("as_rate_bridge.json")["outputs"] + if output["id"] == "reg17_base_rate" + ] + (ftc,) = [term for term in base_rate["terms"] if "divisor" in term] + assert ftc["divisor"] == 52 + + +# --------------------------------------------------------------------------- +# Crosswalk (MC9) +# --------------------------------------------------------------------------- + + +@st.composite +def _crosswalk_rows(draw) -> list[dict[str, Any]]: + count = draw(st.integers(1, 8)) + rows = [] + for index in range(count): + areas = draw( + st.lists( + st.sampled_from(sorted(AREAS)), min_size=1, max_size=4, unique=True + ) + ) + weights = draw( + st.lists( + st.floats(1e-6, 1e6, allow_nan=False, allow_infinity=False), + min_size=len(areas), + max_size=len(areas), + ) + ) + total = math.fsum(weights) + rows.append( + { + "ta_code": f"{index:03d}", + "shares": [ + {"as_area": area, "population_share": weight / total} + for area, weight in zip(areas, weights, strict=True) + ], + } + ) + return rows + + +class TestCrosswalk: + def test_committed_rows_satisfy_the_share_contract(self) -> None: + document = _load("as_area_crosswalk.json") + assert crosswalk_errors(document["rows"]) == [] + assert document["method"]["share_tolerance"] == SHARE_TOLERANCE + # An empty crosswalk can only ship as an explicit harvest item. + assert (document["status"] == "requires_harvest") == (not document["rows"]) + + def test_area_definitions_are_cited_and_list_each_unit_once(self) -> None: + definitions = _load("as_area_crosswalk.json")["area_definitions"] + assert definitions["source"]["url"].startswith( + "https://www.workandincome.govt.nz/" + ) + assert definitions["source"]["retrieved_on"] + assert "residual" in definitions["areas"]["4"] + listed = [ + name + for area in ("1", "2", "3") + for location in definitions["areas"][area]["locations"] + for name in location["statistical_area_units_2017"] + ] + assert listed + assert len(set(listed)) == len(listed) + + @settings(max_examples=200, deadline=None) + @given(_crosswalk_rows()) + def test_normalised_population_shares_pass(self, rows) -> None: + assert crosswalk_errors(rows) == [] + + @settings(max_examples=200, deadline=None) + @given(_crosswalk_rows(), st.floats(1e-7, 0.5), st.booleans()) + def test_any_share_drift_beyond_tolerance_fails(self, rows, drift, upward) -> None: + share = rows[0]["shares"][0] + moved = share["population_share"] + (drift if upward else -drift) + if not 0 <= moved <= 1: + moved = share["population_share"] - (drift if upward else -drift) + if not 0 <= moved <= 1: + moved = share["population_share"] / 2 if share["population_share"] else 0.5 + share["population_share"] = moved + total = math.fsum(item["population_share"] for item in rows[0]["shares"]) + assert abs(total - 1.0) > SHARE_TOLERANCE + assert any(error.startswith("000:") for error in crosswalk_errors(rows)) + + @settings(max_examples=50, deadline=None) + @given(_crosswalk_rows()) + def test_boolean_areas_are_refused(self, rows) -> None: + rows[0]["shares"][0]["as_area"] = True + assert any("integer" in error for error in crosswalk_errors(rows)) + + @settings(max_examples=200, deadline=None) + @given(_crosswalk_rows()) + def test_modal_alternative_is_a_listed_area_or_refuses(self, rows) -> None: + for row in rows: + try: + area = modal_area(row["shares"]) + except ValueError: + continue + assert area in {share["as_area"] for share in row["shares"]} + assert ( + max(row["shares"], key=lambda s: s["population_share"])["as_area"] + == area + ) + + +# --------------------------------------------------------------------------- +# Scenarios (§2.4, MC3a) +# --------------------------------------------------------------------------- + + +class TestScenarios: + def test_committed_grid_satisfies_the_scenario_contract(self) -> None: + document = _load("scenarios.json") + assert scenario_errors(document) == [] + assert [scenario["id"] for scenario in document["scenarios"]] == [ + "S0", + "S1", + "S2", + "S3", + "S4", + "S5", + "V1", + "V2", + "V3", + ] + branch_points = { + tier: row["branch_point"] for tier, row in document["tiers"].items() + } + assert branch_points == {"entitlement": "nz.as", "calibration": "nz.open"} + assert [s["id"] for s in document["scenarios"] if not s["knobs"]] == ["S0"] + + def test_input_overrides_name_real_accommodation_supplement_inputs(self) -> None: + surface = _input_surface() + as_inputs = set( + surface[_bindings()["accommodation_supplement"]["rulespec_path"]][ + "canonical_inputs" + ] + ) + for scenario in _load("scenarios.json")["scenarios"]: + for override in scenario.get("input_overrides", []): + assert override["variable"] in as_inputs + + def test_annualisation_is_the_declared_mc12_convention(self) -> None: + conventions = _load("scenarios.json")["gap_conventions"] + assert conventions["annualisation"]["weeks_per_year"] == 52 + assert conventions["annualisation"]["method_card_row"] == "MC12" + + @settings(max_examples=200, deadline=None) + @given(st.data()) + def test_a_scenario_with_two_or_no_method_card_rows_is_refused(self, data) -> None: + document = _load("scenarios.json") + index = data.draw(st.integers(0, len(document["scenarios"]) - 1)) + bad_row = data.draw( + st.sampled_from(["", "MC", "MC0", "MC16", "MC1, MC5", "MC1 MC5", "mc3"]) + ) + document["scenarios"][index]["method_card_row"] = bad_row + assert any( + "exactly one method-card row" in error + for error in scenario_errors(document) + ) + + @settings(max_examples=200, deadline=None) + @given(st.data()) + def test_a_scenario_outside_the_two_tiers_is_refused(self, data) -> None: + document = _load("scenarios.json") + index = data.draw(st.integers(0, len(document["scenarios"]) - 1)) + tier = data.draw(st.text(max_size=12).filter(lambda value: value not in TIERS)) + document["scenarios"][index]["tier"] = tier + assert any("tier outside" in error for error in scenario_errors(document)) + + @settings(max_examples=200, deadline=None) + @given(st.data()) + def test_a_scenario_changing_two_knobs_is_refused(self, data) -> None: + document = _load("scenarios.json") + changed = [s for s in document["scenarios"] if s["knobs"]] + scenario = data.draw(st.sampled_from(changed)) + (knob,) = scenario["knobs"] + other = data.draw( + st.sampled_from(sorted(set(document["central_knobs"]) - {knob})) + ) + scenario["knobs"][other] = document["central_knobs"][other] + assert any("exactly one knob" in error for error in scenario_errors(document)) + + +# --------------------------------------------------------------------------- +# Remaining resources +# --------------------------------------------------------------------------- + + +class TestDeclaredResources: + def test_threshold_gates_await_d1_and_carry_no_numeric_threshold(self) -> None: + spec = load_country_spec("nz") + by_function = {gate.gate: gate for gate in spec.gates.gates} + for function in ( + "per_family_fit", + "aggregate_admin", + "weight_ess", + "weight_ratio", + ): + assert by_function[function].not_applicable.startswith("awaiting D1") + + def numeric_leaves(value: object) -> Iterator[object]: + if isinstance(value, Mapping): + for child in value.values(): + yield from numeric_leaves(child) + elif isinstance(value, (list, tuple)): + for child in value: + yield from numeric_leaves(child) + elif isinstance(value, (int, float)) and not isinstance(value, bool): + yield value + + assert not [ + leaf + for gate in spec.gates.gates + for leaf in numeric_leaves(gate.parameters) + ] + + def test_currency_bridge_is_one_declared_multiplication(self) -> None: + bridge = _load("currency_bridge.json") + assert (bridge["from_currency"], bridge["to_currency"]) == ("USD", "NZD") + assert bridge["operation"] == "multiply" + assert bridge["rounding"] == {"decimal_places": 2} + assert isinstance(bridge["rate"], float) and bridge["rate"] > 0 + assert bridge["source"]["url"].startswith("https://www.irs.gov/") + assert bridge["source"]["column"] == str(bridge["rate_year"]) + + def test_benefit_unit_rule_refuses_to_guess_the_dependent_child_age(self) -> None: + rule = _load("benefit_unit_rule.json") + assert rule["entity"] == "family" and rule["engine_entity"] == "Family" + assert rule["dependent_child"]["age_limit_years"] is None + assert rule["dependent_child"]["candidate_basis"].startswith("UNVERIFIED") + assert set(rule["membership_columns"]) == { + "family_id", + "family_household_id", + "person_family_id", + } + + def test_support_contract_receipts_match_the_package_files(self) -> None: + # Differential: sources.yaml records the bytes of the two contracts. + sources = load_yaml12( + (NZ_ROOT / "spec/sources.yaml").read_text(encoding="utf-8"), + source="nz/spec/sources.yaml", + ) + contracts = { + "nz_populace_us_support_contract": "source_stages.json", + "nz_region_assignment_contract": "geography_spine.json", + } + rows = {row["id"]: row for row in sources["sources"]} + assert set(rows) == set(contracts) + for source_id, name in contracts.items(): + raw = (NZ_ROOT / name).read_bytes() + assert rows[source_id]["sha256"] == hashlib.sha256(raw).hexdigest() + assert rows[source_id]["byte_size"] == len(raw) + + def test_donor_pin_and_us_support_provenance_are_explicit(self) -> None: + stages = load_country_spec("nz").sources.stage_map() + assert tuple(stages) == ("load_populace_us_support_pool",) + donor = stages["load_populace_us_support_pool"] + (artifact,) = donor.artifacts + assert artifact["revision"] == ( + "populace-us-2024-buildp-sparse-rmloss100-cae8640-20260728T011454Z" + ) + assert artifact["sha256"] == ( + "48b9d479fb4fd1c3537f9383ce4697d130b6f618658409d74f6233c43b994c7e" + ) + assert artifact["size_bytes"] == 462915783 + assert {"donor_country_code", "support_stratum"} <= set(donor.outputs) + assert "US donor support records" in donor.survey diff --git a/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_country_bundles.py b/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_country_bundles.py index cf710bfce1..348776d52c 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_country_bundles.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_country_bundles.py @@ -32,6 +32,25 @@ }, {"household", "person"}, ), + ( + "nz", + 2026, + { + "family.family_household_id", + "family.family_id", + "household.household_id", + "household.household_weight", + "person.age", + "person.donor_country_code", + "person.person_family_id", + "person.person_household_id", + "person.person_id", + "person.region_code", + "person.sex", + "person.support_stratum", + }, + {"family", "household", "person"}, + ), ( "uk", 2025, @@ -76,6 +95,7 @@ def test_country_bundle_loads_once_and_compiles_through_the_shared_core( def test_country_bundles_exercise_distinct_support_and_geography_kinds() -> None: am = compile_spec(load_bundle("am")) be = compile_spec(load_bundle("be")) + nz = compile_spec(load_bundle("nz")) uk = compile_spec(load_bundle("uk")) assert am.resource("spine")["support_roles"] == [ @@ -84,6 +104,9 @@ def test_country_bundles_exercise_distinct_support_and_geography_kinds() -> None assert be.resource("spine")["support_roles"] == [ {"id": "silc_base", "kind": "none"} ] + assert nz.resource("spine")["support_roles"] == [ + {"id": "populace_us_donor_base", "kind": "none"} + ] assert uk.resource("spine")["support_roles"] == [ { "id": "spi_income_support", @@ -99,6 +122,10 @@ def test_country_bundles_exercise_distinct_support_and_geography_kinds() -> None "assign": "kernel:clone_assign_communes", "validate": "kernel:be_commune_geography_gate", } + assert nz.resource("geography")["assignment"]["kernels"] == { + "assign": "kernel:assign_nz_regions", + "validate": "kernel:nz_region_geography_gate", + } assert uk.resource("geography")["assignment"]["kernels"] == { "assign": "kernel:assign_uk_geography_ladder", "validate": "kernel:uk_geography_ladder_gate", @@ -109,12 +136,14 @@ def test_country_kernel_contract_ids_are_closed_in_the_compiler_registry() -> No country_contract_ids = { "am_community_geography_gate", "assign_am_marz", + "assign_nz_regions", "clone_assign_communities", "load_populace_us_support_pool", "silc_load", "clone_assign_communes", "be_commune_geography_gate", "load_uk_national_frame", + "nz_region_geography_gate", "build_uk_frs_spine", "assign_uk_geography_ladder", "uk_geography_ladder_gate", @@ -180,6 +209,34 @@ def test_be_generation_zero_views_still_come_from_the_country_spec_seam() -> Non } +def test_nz_generation_zero_views_come_from_the_country_spec_seam() -> None: + spec = load_country_spec("nz") + + assert spec.sources is not None + assert tuple(spec.sources.stage_map()) == ("load_populace_us_support_pool",) + assert spec.geography_spine is not None + assert spec.geography_spine.geography_spine.stage == "assign_nz_regions" + assert spec.gates is not None + assert spec.release_contract is not None + assert not spec.release_contract.artifact_repo_private + assert {row.path for row in spec.resource_rows if row.kind == "legacy_json"} == { + "as_area_crosswalk.json", + "as_rate_bridge.json", + "axiom_rules_bindings.json", + "benefit_unit_rule.json", + "currency_bridge.json", + "export_contract.json", + "gates.json", + "geography_spine.json", + "holdout_references.json", + "precal_references.json", + "release_contract.json", + "scenarios.json", + "source_stages.json", + "target_references.json", + } + + @pytest.mark.parametrize( ("before", "after", "field"), [ @@ -287,3 +344,30 @@ def test_am_smoke_build_refuses_without_harvests_and_stage_bindings() -> None: ), ): country_stage_plan(spec, {}) + + +def test_nz_smoke_build_refuses_without_evidenced_inputs_and_stage_bindings() -> None: + """A valid spec package must not turn missing NZ runtime bindings into stubs.""" + + spec = load_country_spec("nz") + compiled = compile_spec(spec.resolved_spec) + assert compiled.spec_binding.country == "nz" + assert not compiled.producer_graph.present + assert compiled.nodes == () + + stages = spec.sources.stage_map() + assert { + artifact["kind"] + for artifact in stages["load_populace_us_support_pool"].artifacts + } == {"public_microdata"} + build_package = _TEST_PATHS.package / "src/microcosm/build" + assert not (build_package / "nz_runtime").exists() + + with pytest.raises( + ValueError, + match=( + r"missing \['load_populace_us_support_pool', 'assign_nz_regions'\].*" + r"There are no stubs or fallbacks" + ), + ): + country_stage_plan(spec, {}) diff --git a/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_reemission.py b/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_reemission.py index 5b5fada43f..4c4b6fcd44 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_reemission.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_spec_engine_reemission.py @@ -44,7 +44,7 @@ def _mutate_yaml(path: Path, mutation) -> None: path.write_bytes(canonical_yaml_bytes(value, source=str(path))) -@pytest.mark.parametrize("country", ["be", "uk", "us"]) +@pytest.mark.parametrize("country", ["be", "nz", "uk", "us"]) def test_resolved_bundle_round_trip_is_lossless_deterministic_and_alias_free( tmp_path: Path, country: str, diff --git a/packages/microcosm-build/tests/engine_free/shared/test_spec_only_country_packages.py b/packages/microcosm-build/tests/engine_free/shared/test_spec_only_country_packages.py index d8415b236d..41c48e001d 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_spec_only_country_packages.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_spec_only_country_packages.py @@ -161,6 +161,37 @@ def test__given_greenfield_country_packages__then_armenia_uses_the_typed_schema( } +def test__given_greenfield_country_packages__then_new_zealand_uses_the_typed_schema() -> ( + None +): + # Given + country_roots = {root.name: root for root in _country_package_roots()} + + # When + new_zealand = country_roots["nz"] + manifest = json.loads( + (new_zealand / "country_package.json").read_text(encoding="utf-8") + ) + resource_kinds = {row["kind"] for row in manifest["resources"]} + legacy_paths = { + row["path"] for row in manifest["resources"] if row["kind"] == "legacy_json" + } + + # Then + assert manifest["schema_version"] == 1 + assert resource_kinds == ALLOWED_COUNTRY_RESOURCE_KINDS & { + "bundle", + "catalogs", + "geography", + "legacy_json", + "sources", + "spine", + "vintages", + } + assert all(PurePosixPath(path).suffix == ".json" for path in legacy_paths) + assert not (COUNTRY_PACKAGE_ROOT / "nz_runtime").exists() + + def test__given_country_specs__then_no_python_entrypoints_are_declared() -> None: # Given specs = [ diff --git a/packages/microcosm-build/tests/golden/nz_country_spec.json b/packages/microcosm-build/tests/golden/nz_country_spec.json new file mode 100644 index 0000000000..adc7f40194 --- /dev/null +++ b/packages/microcosm-build/tests/golden/nz_country_spec.json @@ -0,0 +1,94 @@ +{ + "country": "nz", + "fingerprint": "5077683cacb37805e9047fec3ccbaf9bddbb156c410bb41339e360f2107644bb", + "gate_ids": [ + "calibration_per_family_fit", + "national_and_region_admin_aggregates", + "calibration_reference_inventory", + "target_profile_coverage", + "release_input_coverage", + "donor_support_bounds", + "calibration_weights_audit", + "exported_nonzero", + "formula_owned_export", + "nonnegative_columns", + "weight_effective_sample_size", + "weight_max_to_median_ratio", + "macro_realism_nz" + ], + "geography_spine_stage": "assign_nz_regions", + "release": { + "artifact_repo": "policyengine/populace-nz", + "builder": "populace-nz", + "dataset_filename_template": "populace_nz_{year}.h5", + "required_release_files": [ + "populace_nz_{year}.h5", + "populace_nz_{year}_calibration.npz", + "build_manifest.json", + "release_manifest.json", + "calibration_diagnostics.json", + "source_coverage.json", + "demographics.json", + "reform_validation.json" + ], + "staging_repo": "policyengine/populace-nz-staging" + }, + "resource_hashes": { + "as_area_crosswalk.json": "cfb76c99898204598b0609e5462b2fba9d081872a13ac5dd973cb406bb6f7469", + "as_rate_bridge.json": "deffa41a6bbb8453df0e63d2bf28573da2e280a33b18ad46642ad35442b67049", + "axiom_rules_bindings.json": "ac2f2fda757ab5ac9ff284b1f9dfea712d2811dc5b289ed5c810d35527acdc02", + "benefit_unit_rule.json": "65c7c8ec9bd9d1ae11e680bf331a17a8f521185c0bb4ac487e53979fe5062220", + "country_package.json": "b83de9e12238b41481810ddb7fbdbaaa71d457cbfa9da83f3f53cbf479cee43b", + "currency_bridge.json": "b364db79565e2fba51160bcf5489612b81b51b0aeb7ae569a908a748815281ce", + "export_contract.json": "1a66f9f8bc820a28b6b8c04d3f5b1ebf43902e9903b698590000264745d98c39", + "gates.json": "3d709d88bd40198b61b9a1745b6f4481625145aa13a7414d700726c50159a0ae", + "geography_spine.json": "353821844d85d2622c328b4986dd9e3a8075c13498b9f56d349c4578edf7495a", + "holdout_references.json": "52b5a2360502156da1392a68350e4e7f61b5fd420fa74428819b65886a9f8655", + "precal_references.json": "43b13849e3d74ebca7d7a9d46817463e3ad1333c3aedd7a3eac39e870510fba0", + "release_contract.json": "c0acc8122acf90e7a1d0bcb363ac95dd92928d9f521608525f729df10de421a2", + "scenarios.json": "5dbf2b9603fa55fd9abd7319e188f882ffe471e99e7d691b81980522a9bef82f", + "source_stages.json": "a4e5bb6801e8f2b8bf539dcb739aaa35c4d2162909d61f18327932e7e252aae7", + "spec/bundle.yaml": "25ef74ecbc00c3f2ecca2d30ddd5e46a582657863c217da33ee9a396569d2cf2", + "spec/catalogs.yaml": "3ab2025af39cba93ead707b9463e428430a45b30696a43d0906cbcd97ed36631", + "spec/geography.yaml": "0dbf9f850dc3cef983f166bf6783022094894ec2461ad71f4809607bcfd390a9", + "spec/sources.yaml": "5e94c8f46996e48cc02f92881b57a540aec3893643363b254cda1b5e18c42525", + "spec/spine.yaml": "b3e6a2832ff7671777cae113b7fc45f5e3492bb519526f229118d0851083533e", + "spec/vintages.yaml": "1ecc1a3f952bd25b834c394314e8c933ddb5b095fd64ba492dddb03e3cdd803e", + "target_references.json": "5cc916bc477e250f562469aaf2d144693fa3aa2a7824cc11096ad35b7c13b543" + }, + "resources": [ + "spec/bundle.yaml", + "spec/catalogs.yaml", + "spec/geography.yaml", + "spec/sources.yaml", + "spec/spine.yaml", + "spec/vintages.yaml", + "as_area_crosswalk.json", + "as_rate_bridge.json", + "axiom_rules_bindings.json", + "benefit_unit_rule.json", + "currency_bridge.json", + "export_contract.json", + "gates.json", + "geography_spine.json", + "holdout_references.json", + "precal_references.json", + "release_contract.json", + "scenarios.json", + "source_stages.json", + "target_references.json" + ], + "stage_names": [ + "load_populace_us_support_pool" + ], + "target_reference_names": [ + "stats_nz_population_by_age_sex_region", + "stats_nz_census_households_by_composition", + "stats_nz_census_families_by_type", + "stats_nz_census_households_by_tenure_region", + "ird_taxable_income_people_by_band", + "ird_taxable_income_total_by_band", + "msd_main_benefit_recipients_by_program_age", + "msd_nz_super_veterans_pension_recipients" + ] +} From fe698c9077e467288fcb19b5b66cd665ac4dd0ff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 07:29:41 -0400 Subject: [PATCH 2/2] Apply the #1119 review: hold-out validator, refusal tests, wider scanner Finding 1. The hold-out check is now a validator, holdout_leaks(cal, precal, holdout), in test_nz_spec_package.py. A calibration or pre-calibration reference leaks when it: - has a hold-out name; - shares a Ledger identifier with a hold-out reference; - resolves through a selector that one fact could satisfy together with a hold-out selector; - belongs to a hold-out family. Identifiers are compared across the fact-key and source-record-id fields, as ledger_targets._ledger_fact_index resolves them. Selectors include scaled_by_ratio operands (reference_fact_selectors). As in _fact_matches_selector, a list matches by membership and an empty value is a wildcard. A Hypothesis test plants a fresh name, identifier, selector or family in either upstream set and requires a refusal. It uses cross-field identifiers, five overlapping selector shapes, and ratio operands on either side. The example test on the committed data stays, and each row is now asserted to be identity with no operands. Finding 2. Every refusal branch of crosswalk_errors, scenario_errors and the modal_area tie check has a Hypothesis negative case. The new cases found two holes, now closed: - a crosswalk ta_code of None or a non-string passed; - a rent_stock_factor of inf or True passed as a positive number. Finding 3. The rulespec-commit scanner now treats a 7-40 hex run as a mention when it is: - anywhere in a string that contains "rulespec" (any case); - any key or value below a key containing "rulespec"; - in a record whose repository contains "rulespec", in any spelling; - under a key containing "commit" outside a foreign record; - an all-digit SHA that YAML reads as an integer, or a hex mapping key. The string rule is wider than the requested 40-character window, because the package's own "git diff 6fe181fc 8dc2507" sentence sat 102 characters from "rulespec". A test plants five phrasings into the package payloads: the two the review found and three found by the review of this fix-up. On the committed package the scanner still finds only the two reviewed commits. The docstring names both reviewed commits instead of "the one pin". Finding 4 and further wording: - axiom_rules_bindings.json, as_rate_bridge.json, benefit_unit_rule.json, currency_bridge.json, export_contract.json, source_stages.json and spec/sources.yaml no longer describe unwritten code (packages G1, G4, G5a, G6 and G7) as running. - MC11a reads "proposed (awaiting D1)". - benefit_unit_rule.json cites executor _structural_columns and _validate_population_declaration: family_household_id is not structural. The unreachable parent_2 branch is replaced by the parent-1 rule and the parent_order check. - docs/nz-take-up-engine-plan.md: the "nz appears only in" line, the crosswalk row (a placeholder with rows: []), the existing geography kernels and the MC11a wording. - docs/nz-calibration-targets.md drops "only" from the TAWA WFF sentences and marks DistributionExplorer as a comparator, not a calibration target. - release_contract.json no longer credits reform_validation.json with #821's official-score comparisons. source_stages.json is re-receipted in sources.yaml, and the NZ golden is regenerated. Tests: 322 passed across the PR's nine targeted test files. test_nz_spec_package.py has 70 tests, up from 44. Before the round-2 review, all 57 mutants of the validators turned a test red. On the code round 2 changed, 10 of 10 did; an 11th survived only because its clause was redundant, and that clause is removed. Co-Authored-By: Claude Opus 5.5 --- docs/nz-calibration-targets.md | 8 +- docs/nz-take-up-engine-plan.md | 65 +- .../microcosm/build/nz/as_rate_bridge.json | 4 +- .../build/nz/axiom_rules_bindings.json | 6 +- .../microcosm/build/nz/benefit_unit_rule.json | 12 +- .../microcosm/build/nz/currency_bridge.json | 2 +- .../microcosm/build/nz/export_contract.json | 2 +- .../microcosm/build/nz/release_contract.json | 2 +- .../src/microcosm/build/nz/source_stages.json | 2 +- .../src/microcosm/build/nz/spec/sources.yaml | 6 +- .../shared/test_nz_spec_package.py | 801 ++++++++++++++++-- .../tests/golden/nz_country_spec.json | 18 +- 12 files changed, 823 insertions(+), 105 deletions(-) diff --git a/docs/nz-calibration-targets.md b/docs/nz-calibration-targets.md index e009d38868..1049e97b61 100644 --- a/docs/nz-calibration-targets.md +++ b/docs/nz-calibration-targets.md @@ -24,7 +24,7 @@ Ranked by calibration value for a dollar-level take-up estimate. **$** = enables | 2 | **Benefit expenditure by program, in dollars** | MSD *Annual Report 2025* — *Benefits or Related Expenses* schedule | $ | The dollar control totals: NZS $23.19B, Jobseeker+Emergency $4.64B, Accommodation Assistance $2.23B, SLP $2.67B, Sole Parent $2.26B, Hardship $0.75B, WEP $0.56B (2024/25). Explicit CC BY 4.0. [FETCH-OK] | | 3 | **Benefit recipient counts by benefit × age × ethnicity × duration × region** | MSD *Quarterly Benefit Fact Sheets* (Mar 2026) | # | Recipient marginals for every main benefit + supplementary (AS, DA, TAS). National→region→TA→service-centre. Count-only. [FETCH-OK] | | 4 | **Working for Families: expenditure, families, average claim by credit type** | IRD *Working for Families statistics Sept 2025* | $ / # | WfF dollar + count margins by FTC/IWTC/MFTC, family size (2020+), recipient income distribution (2020+). [FETCH-OK] | -| 5 | **Modelled dollar amounts per program by income position** | Treasury *DistributionExplorer* data CSVs (HES24 + BEFU25, TY25–29) | $ / # | TAWA-modelled $ per program (AS, Core Benefits, FTC, IWTC, MFTC, BestStart, WFF, WEP, FamilyBoost, NZ Super, Income Tax) × Household/Family/Individual × income quantile/band, with weighted population per cell. AS values are assigned by TAWA's take-up model, so for AS this is an independent comparison, not full entitlement; TAWA assumes full take-up only for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). MIT. [FETCH-OK] | +| 5 | **Modelled dollar amounts per program by income position** | Treasury *DistributionExplorer* data CSVs (HES24 + BEFU25, TY25–29) | $ / # | TAWA-modelled $ per program (AS, Core Benefits, FTC, IWTC, MFTC, BestStart, WFF, WEP, FamilyBoost, NZ Super, Income Tax) × Household/Family/Individual × income quantile/band, with weighted population per cell. AS values are assigned by TAWA's take-up model, so for AS this is an independent comparison, not full entitlement; TAWA assumes full take-up for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). A validation comparator only, never a calibration target (Gaps / risks, item 6). MIT. [FETCH-OK] | | 6 | **Age × sex × region / territorial authority population** | Stats NZ *Subnational population estimates at 30 June 2025* (2023-base) | # | Primary demographic reweighting frame: 16 regions / 67 TAs / 21 Auckland boards by age×sex. [SNIPPET; prior-quarter direct xlsx FETCH-OK] | | 7 | **Accommodation Supplement recipients + average payment** | MSD Fact Sheets (counts, by W&I region/TA) + Annual Report (Accommodation Assistance $2.23B) | $ / # | AS is the take-up wedge (~44% receipt among potentially eligible). Count × implied average payment = AS dollar base. [FETCH-OK] | | 8 | **Household composition / family type** | Stats NZ 2023 Census — families, households and housing | # | Household/family-type marginals (1-family 67.7%; 1,780,527 total households). [SNIPPET] + ArcGIS/DataFinder [FETCH-OK for hub] | @@ -37,7 +37,7 @@ Ranked by calibration value for a dollar-level take-up estimate. **$** = enables | 15 | **Minimum wage series + average weekly earnings** | MBIE minimum wage ($23.95 from Apr 2026) + Stats NZ QES/LEED | $ | Anchors the bottom of the earnings distribution and the national/TA earnings margin. [FETCH-OK min wage; SNIPPET QES/LEED] | **Dollar-level calibration design (numerator/denominator):** -- **Denominator (full-entitlement modelled $):** our own rules × calibrated population. Treasury DistributionExplorer CSVs (#5) are TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement, for AS; TAWA assumes full take-up only for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). +- **Denominator (full-entitlement modelled $):** our own rules × calibrated population. Treasury DistributionExplorer CSVs (#5) are TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement, for AS; TAWA assumes full take-up for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). - **Numerator (actual $ paid):** MSD Annual Report expenditure schedule (#2) and AN24/01 fiscal totals (#10); IRD WfF expenditure (#4) for in-work credits. - **Average payment** = program expenditure (#2) ÷ recipient count (#3) distributes dollars across the calibrated population. - **Take-up gap** = actual paid vs full-entitlement modelled, anchored empirically by the WfF ~87% and AS ~44% take-up studies (M5/M6 below). @@ -101,7 +101,7 @@ Notes: **I1, I3, I4 are the load-bearing IRD targets.** IRD income tables are ** ## 4. Treasury -Public analysis ships as **code + data on GitHub** and interactive tools. DistributionExplorer (T1) outputs are TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement, for AS and for core benefits (paid only to administrative recipients); TAWA assumes full take-up only for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). They are not actuals. +Public analysis ships as **code + data on GitHub** and interactive tools. DistributionExplorer (T1) outputs are TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement, for AS and for core benefits (paid only to administrative recipients); TAWA assumes full take-up for the WFF credits ([TAWA methodology](https://treasury.govt.nz/sites/default/files/2024-08/tawa-model-methodology.pdf), "Modelling take-up"). They are not actuals. | # | Dataset | Access | Contents | Period | Licence | Status | |---|---|---|---|---|---|---| @@ -144,7 +144,7 @@ Also: `github.com/Treasury-Analytics-and-Insights/Calibration` (MIT, 2018) — " 3. **AS by Area 1–4 is not a standing series** — need TA→Area crosswalk (H4) or OIA; known break at 2027-04-01 (H4c redraw). 4. **IRRS dollars unconfirmed in open dashboard** — source from Kāinga Ora/Vote Housing. 5. **Benefit expenditure is annual + national only** — regional dollars must assume uniform average payment within benefit (flag as modelling assumption). -6. **Treasury DistributionExplorer is TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement.** Only its WFF credits assume full take-up. Never actuals, never a calibration target. +6. **Treasury DistributionExplorer is TAWA-modelled; AS values assigned by TAWA's take-up model; an independent comparison, not full entitlement.** Its WFF credits assume full take-up. Never actuals, never a calibration target. 7. **FamilyBoost unvalidatable** against admin data today. 8. **Household/family projections stale (2013-base)** — use census counts. 9. **Portal migration risk**: Infoshare → ADE Time Series (April 2027) will change URLs/schemas; pin to file CDNs. diff --git a/docs/nz-take-up-engine-plan.md b/docs/nz-take-up-engine-plan.md index 51e5b84edd..e162915e6f 100644 --- a/docs/nz-take-up-engine-plan.md +++ b/docs/nz-take-up-engine-plan.md @@ -38,11 +38,20 @@ The two region-geography kernel ids, `assign_nz_regions` and ## How the build runs The whole v0 build, from the donor file to the AS gap bands, is planned as -one `microcosm.graph.Graph` run with `run_graph` over a content store. There -is no `nz_runtime/` package: kernels are country-neutral (`transport.*`, -`concepts.*`, `takeup.*`, `targets.*`, `gates.*`, `export.*`, -`diagnostics.*`, `simulate.*`), and everything New Zealand-specific is spec -data in `build/nz/`. "nz" appears only in node ids and spec data. +one `microcosm.graph.Graph` run with `run_graph` over a content store. None +of its nodes exists yet. Most of its kernels are still to be written; the +geography kernels `geography.assign_atomic@1`, `geography.derive@1` and +`geography.gate@1` already exist and are to be reused unchanged. The plan +has no `nz_runtime/` package: kernels are to be country-neutral +(`transport.*`, `concepts.*`, `geography.*`, `takeup.*`, `targets.*`, +`calibrate.*`, `gates.*`, `export.*`, `diagnostics.*`, `simulate.*`), and +the New Zealand specifics are to be data: the spec in `build/nz/` and the +Frame concept mapping `adapters/axiom_concept_mappings/nz.json`. In +package source (`packages/*/src`), the New Zealand-specific identifiers are +to be the two contract-only resolver ids above (`assign_nz_regions`, +`nz_region_geography_gate`) and the Axiom adapter's `NZ_SCHEMA` entity +schema, which package G1 is to add. Graph node ids (`nz.create`, …) also +name New Zealand. Version layout: @@ -56,19 +65,26 @@ nz.create (CREATE) ── nz.open (FILTER keep-all: transport, geography, receip └── nz.v.V1 … nz.v.V3 (FILTER each: variant → calibrate → AS → gap) ``` -- **CREATE builds the benefit units.** Entity ids and memberships are - structural columns, so the NZ `family` benefit unit is built inside CREATE, - with design weights installed at the Stats NZ population scale. +- **CREATE is to build the benefit units.** Entity ids and person + memberships are structural columns, which the graph executor does not let + a non-structural node own, so the NZ `family` benefit unit is to be built + inside CREATE, with design weights installed at the Stats NZ population + scale. - **`nz.calibrate` has no members.** Scenarios, validation and terminal nodes hang off FILTER branches, so adding one never re-keys calibration. - **Hold-out by ancestry.** No ancestor of a calibration node may read an AS output, the hold-out references, or the hold-out Chronicle artifact. The - composer package (G6) adds a test over the compiled graph's predecessors - that checks this. -- **Spec values enter node params.** Each node carries the resolved values it - reads and a digest of where they came from; no kernel's implementation hash - binds the whole NZ spec fingerprint, which would re-key every spec-reading - node on any spec edit. + composer package (G6) is to add a test over the compiled graph's + predecessors that checks this. Until then, `test_nz_spec_package.py` + checks the hold-out only as data: no calibration or pre-calibration + reference shares a hold-out reference's name, Ledger identifier or family, + or has a selector that could match the same Ledger fact. Without the + Ledger feed it cannot match an identifier on one side against a selector + on the other. +- **Spec values enter node params.** Each node is to carry the resolved + values it reads and a digest of where they came from; no kernel's + implementation hash is to bind the whole NZ spec fingerprint, which would + re-key every spec-reading node on any spec edit. ## Spec resources @@ -78,7 +94,7 @@ nz.create (CREATE) ── nz.open (FILTER keep-all: transport, geography, receip | `benefit_unit_rule.json` | adult + partner + dependent children; other adults form their own unit; refusals | `nz.create` | MC7 | | `currency_bridge.json` | USD→NZD 1.654 (IRS 2024 yearly average), one multiplication, 2 dp | `nz.transport.currency` | MC4 | | `precal_references.json` | quantile-map destinations (IRD wage bands, MBIE rents, Stats NZ financial net worth), TA population, MSD M1 and O4 for take-up draws | `nz.facts.precal` | MC4, MC6, MC8–MC10 | -| `as_area_crosswalk.json` | TA → AS area population shares; Work and Income area definitions | `nz.geo.support` | MC9 | +| `as_area_crosswalk.json` | Work and Income area definitions; the TA → AS area population shares are a placeholder (`rows: []`, `status: requires_harvest`) until harvested | `nz.geo.support` | MC9 | | `axiom_rules_bindings.json` | rulespec-nz pin, five module bindings, variables, the `2026-27` tax-year period | every Axiom-bound node | MC1 | | `target_references.json` | the calibration set (MC5 list) | `nz.targets.compile` | MC5 | | `as_rate_bridge.json` | reg 17 base rate and reg 18 cutout composition | `nz.as.bridge.*` | MC11 | @@ -87,9 +103,9 @@ nz.create (CREATE) ── nz.open (FILTER keep-all: transport, geography, receip | `gates.json` | the gate battery; thresholds await D1 | `nz.gates.calibrated` | MC15 | | `export_contract.json` | the closed export contract | export nodes | — | -The three reference sets are separate files on purpose. A node carries the -digest of the resource it reads, so a hold-out edit cannot move a -calibration-side key, and a new calibration target does not re-run +The three reference sets are separate files on purpose. Each planned node is +to carry the digest of the resource it reads, so a hold-out edit cannot move +a calibration-side key, and a new calibration target does not re-run transport. ### Reference sets @@ -114,17 +130,18 @@ branch. The AS module takes the reg 17 base rate and the reg 18 non-beneficiary cutout as inputs. Until rulespec-nz encodes those regulations, -`as_rate_bridge.json` composes them from engine evaluations of the bound +`as_rate_bridge.json` declares how the bridge nodes (package G7, not yet +written) are to compose them from engine evaluations of the bound main-benefit and family-tax-credit modules, porting the statutory branch of -the IncomeExplorer conformance harness. This is the one sanctioned exception -to "real Axiom runtime only" (method card amendment MC11a) and needs Max's -MC11 ruling. +the IncomeExplorer conformance harness. Under method card amendment MC11a, +which is proposed (awaiting D1), this would be the one sanctioned exception +to "real Axiom runtime only"; it needs Max's MC11 ruling. The sole-parent cutout cannot be a zero-solve on the unit's own Jobseeker schedule. The engine applies Income Test 1 to a single person with dependent children, while the harness pairs the single-with-children rate with the -Income Test 3 slope. The bridge recovers the threshold, the slope and the -gross rate from three engine evaluations instead. Golden-08's base rate +Income Test 3 slope. The bridge declaration recovers the threshold, the slope +and the gross rate from three engine evaluations instead. Golden-08's base rate (673.846923…) and cutout (905.028571…) are the differential expectations. ### What is still open diff --git a/packages/microcosm-build/src/microcosm/build/nz/as_rate_bridge.json b/packages/microcosm-build/src/microcosm/build/nz/as_rate_bridge.json index 4cd6f0ada2..b64820a453 100644 --- a/packages/microcosm-build/src/microcosm/build/nz/as_rate_bridge.json +++ b/packages/microcosm-build/src/microcosm/build/nz/as_rate_bridge.json @@ -2,8 +2,8 @@ "version": 1, "country": "nz", "method_card_row": "MC11", - "description": "Host bridge for the two law-derived Accommodation Supplement inputs that rulespec-nz takes as inputs \"for later cross-module wiring\": the Social Security Regulations 2018 reg 17 base rate and the reg 18 non-beneficiary income cutout. Every rate comes from an engine evaluation of a bound rulespec-nz module; this resource declares only which outputs combine, with which coefficients, under which input overrides. It ports the statutory branch (treasury_host_aligned false) of the axiom-oracles IncomeExplorer conformance harness. It is the one sanctioned exception to \"real Axiom runtime only\" (method card amendment MC11a) and needs Max's MC11 ruling. When rulespec-nz encodes regs 17 and 18 (6 Oct plan B13) this resource and the bridge graph nodes are deleted; only the Accommodation Supplement branches re-run.", - "status": "awaiting D1 (MC11, MC11a)", + "description": "Host bridge for the two law-derived Accommodation Supplement inputs that rulespec-nz takes as inputs \"for later cross-module wiring\": the Social Security Regulations 2018 reg 17 base rate and the reg 18 non-beneficiary income cutout. Every rate is to come from an engine evaluation of a bound rulespec-nz module; this resource declares only which outputs combine, with which coefficients, under which input overrides. No bridge graph node exists yet (package G7). It ports the statutory branch (treasury_host_aligned false) of the axiom-oracles IncomeExplorer conformance harness. Under method card amendment MC11a, which is proposed (awaiting D1), it would be the one sanctioned exception to \"real Axiom runtime only\"; it needs Max's MC11 ruling. When rulespec-nz encodes regs 17 and 18 (6 Oct plan B13), this resource and the bridge graph nodes are to be deleted, and only the Accommodation Supplement branches will re-run.", + "status": "awaiting D1 (MC11; amendment MC11a proposed)", "source": { "repository": "TheAxiomFoundation/ops", "path": "nz-lane/emtr_reproduction/run.py", diff --git a/packages/microcosm-build/src/microcosm/build/nz/axiom_rules_bindings.json b/packages/microcosm-build/src/microcosm/build/nz/axiom_rules_bindings.json index 43acffbd1b..521e05be84 100644 --- a/packages/microcosm-build/src/microcosm/build/nz/axiom_rules_bindings.json +++ b/packages/microcosm-build/src/microcosm/build/nz/axiom_rules_bindings.json @@ -1,7 +1,7 @@ { "version": 1, "country": "nz", - "description": "Axiom rules bindings for the New Zealand v0 graph build. Each binding names one rulespec-nz module by repository-relative rulespec_path and SHA-256 at the pinned commit, the Frame entity it runs on, the period label it is evaluated for, and the variables the graph requests. The shared composer builds one AxiomEngine per binding and registers every binding in one simulate.rules_by_ref@1 kernel, because a run holds one kernel per ref. The adapter refuses modules that declare relations; every bound module declares none at the pin. No kernel implementation hash binds the whole NZ spec.", + "description": "Axiom rules bindings for the New Zealand v0 graph build. Each binding names one rulespec-nz module by repository-relative rulespec_path and SHA-256 at the pinned commit, the Frame entity it runs on, the period label it is evaluated for, and the variables the graph requests. No code interprets these bindings yet. The planned shared composer (package G6) will build one AxiomEngine per binding and register every binding in one simulate.rules_by_ref@1 kernel (to be added by package G1), because a run's kernel registry holds one kernel per ref. The Axiom adapter raises when a module declares relations; every bound module declares none at the pin. No kernel implementation hash is to bind the whole NZ spec.", "rulespec": { "repository": "TheAxiomFoundation/rulespec-nz", "commit": "8dc2507d5f10a0467c285fed54d018ee37246798", @@ -14,7 +14,7 @@ "repository": "TheAxiomFoundation/axiom-rules-engine", "commit": null, "wheel_sha256": null, - "status": "awaiting the hub engine pin at M0 (D1 method card MC1); the composer refuses to run until both pins are set" + "status": "awaiting the hub engine pin at M0 (D1 method card MC1); the composer (package G6, not yet written) is to refuse to run until both pins are set" }, "entity_names": { "person": "Person", @@ -130,5 +130,5 @@ "notes": "Used only for the eldest-child family tax credit amount in the reg 17 base rate. The module also declares 4 rules on an engine Child entity that the NZ schema does not map; the bridge requests none of them, and G1/G7 must show the adapter compiles the module under that condition or stop." } ], - "keying_note": "An Axiom-bound node carries its own binding entry, the rulespec and engine pin blocks, the entity names and its period, plus a digest of exactly those values, never this file's SHA-256. Otherwise an Accommodation Supplement edit would re-key the main-benefit and NZ Super nodes, which sit upstream of calibration. Which graph nodes consume which binding is the composer's business, not this resource's." + "keying_note": "Each Axiom-bound node is to carry its own binding entry, the rulespec and engine pin blocks, the entity names and its period, plus a digest of exactly those values, never this file's SHA-256. Otherwise an Accommodation Supplement edit would re-key the main-benefit and NZ Super nodes, which sit upstream of calibration. Which graph nodes consume which binding is the composer's business, not this resource's." } diff --git a/packages/microcosm-build/src/microcosm/build/nz/benefit_unit_rule.json b/packages/microcosm-build/src/microcosm/build/nz/benefit_unit_rule.json index 7462eec5f7..0fe9753e62 100644 --- a/packages/microcosm-build/src/microcosm/build/nz/benefit_unit_rule.json +++ b/packages/microcosm-build/src/microcosm/build/nz/benefit_unit_rule.json @@ -2,8 +2,8 @@ "version": 1, "country": "nz", "method_card_row": "MC7", - "description": "The New Zealand benefit unit (engine Family entity): an adult, their co-resident partner, and their dependent children; every other adult forms their own unit. Entity ids and person-to-unit memberships are structural graph columns that only a CREATE node may own, so the shared transport CREATE kernel builds the units from the donor concept pointers, refusing ambiguous or cross-household units. Family weights are never stored: each unit inherits its household's weight through the Frame.", - "status": "awaiting D1 (MC7; the dependent-child rule is spec data under amendment MC11a)", + "description": "The New Zealand benefit unit (engine Family entity): an adult, their co-resident partner, and their dependent children; every other adult forms their own unit. The unit id family.family_id and the membership person.person_family_id are structural graph columns: the graph executor counts each entity's id column and each person-table membership column as structural (microcosm.graph.executor._structural_columns, executor.py lines 636-643) and rejects a non-structural node that declares one as an output (_validate_population_declaration, executor.py lines 1550-1573). family.family_household_id, the unit's link to its household, is not structural; it is an ordinary family column. The planned shared transport CREATE kernel (packages G4 and G5a) is to build the units from the donor concept pointers, refusing ambiguous or cross-household units. Family weights are never stored: each unit inherits its household's weight through the Frame.", + "status": "awaiting D1 (MC7; the dependent-child rule is spec data under amendment MC11a, which is proposed and also awaits D1)", "entity": "family", "engine_entity": "Family", "parent_entity": "household", @@ -32,7 +32,7 @@ }, { "member": "dependent_child", - "rule": "a dependent child joins the unit of a co-resident parent: parent_1_person_id, or parent_2_person_id when parent 1 is absent" + "rule": "a dependent child joins the unit of its co-resident parent parent_1_person_id; a lone co-resident parent is always parent 1 (the pointer definition, concepts.py lines 892-901; the pointer checks flag parent_2_person_id without parent_1_person_id as parent_order, concepts.py _check_pointers), so parent_2_person_id never chooses the unit" }, { "member": "other_adult", @@ -44,11 +44,11 @@ "candidate_age_limit_years": 18, "candidate_basis": "UNVERIFIED proposal. The Social Security Act 2018 definitions of \"child\" and \"dependent child\" were not read in this lane. The hub confirms the statutory text before D1; the CREATE kernel must refuse a null age_limit_years.", "partnered_person_is_dependent_child": false, - "rulespec_encoding": "None at rulespec-nz 8dc2507: nz/statutes/common/relationships.yaml has status deferred and rules: [], and main_benefits/entitlement.yaml encodes only sole_parent_dependent_child_age_limit for the sole-parent test. When rulespec-nz encodes the definition, the CREATE kernel reads it from there and this block is deleted." + "rulespec_encoding": "None at rulespec-nz 8dc2507: nz/statutes/common/relationships.yaml has status deferred and rules: [], and main_benefits/entitlement.yaml encodes only sole_parent_dependent_child_age_limit for the sole-parent test. When rulespec-nz encodes the definition, the CREATE kernel is to read it from there and this block is to be deleted." }, "child_without_coresident_parent": { "central": "household_reference_person_unit", - "status": "proposed default for D1; the kernel refuses when the reference person is themselves a dependent child" + "status": "proposed default for D1; the kernel is to refuse when the reference person is themselves a dependent child" }, "refusals": [ "a unit whose members span two households", @@ -69,7 +69,7 @@ "sum of family weights equals the sum over households of household weight times units in the household" ], "unit_attributes": { - "note": "Unit attributes the units-attributes node emits on the family entity; the reg 17/18 bridge and the group encode read only these.", + "note": "Unit attributes the planned unit-attributes node (transport.unit_attributes@1, package G5a) is to emit on the family entity, for the reg 17/18 bridge and the group encode to read. The bridge's adults_in_couple multiplier (as_rate_bridge.json) is not declared here yet; the bridge kernels (package G7) are to settle it.", "columns": { "family.family_type": "category of the MC7 unit: single, couple, sole parent, couple with children", "family.n_dependent_children": "count of dependent children in the unit", diff --git a/packages/microcosm-build/src/microcosm/build/nz/currency_bridge.json b/packages/microcosm-build/src/microcosm/build/nz/currency_bridge.json index 37e983196f..c87a11e8fd 100644 --- a/packages/microcosm-build/src/microcosm/build/nz/currency_bridge.json +++ b/packages/microcosm-build/src/microcosm/build/nz/currency_bridge.json @@ -2,7 +2,7 @@ "version": 1, "country": "nz", "method_card_row": "MC4", - "description": "The declared USD to NZD bridge for donor money concepts outside the quantile-map set (MC4 quantile-maps earnings, rent and liquid assets onto New Zealand distributions instead). It is exactly one multiplication with two-decimal rounding, applied before calibration and recorded in the build receipt. Aging from the donor dollar year to tax year 2026-27 is a separate declared index (MC1, MC5), not part of this bridge.", + "description": "The declared USD to NZD bridge for donor money concepts outside the quantile-map set (MC4 quantile-maps earnings, rent and liquid assets onto New Zealand distributions instead). It is exactly one multiplication with two-decimal rounding, to be applied before calibration and recorded in the build receipt. Aging from the donor dollar year to tax year 2026-27 is a separate declared index (MC1, MC5), not part of this bridge.", "status": "awaiting D1 (MC4)", "from_currency": "USD", "to_currency": "NZD", diff --git a/packages/microcosm-build/src/microcosm/build/nz/export_contract.json b/packages/microcosm-build/src/microcosm/build/nz/export_contract.json index 83f97703bf..d241df1d02 100644 --- a/packages/microcosm-build/src/microcosm/build/nz/export_contract.json +++ b/packages/microcosm-build/src/microcosm/build/nz/export_contract.json @@ -1,5 +1,5 @@ { - "_description": "Closed Frame export contract for the New Zealand v0 population dataset, which the CLI writes outside the graph after the export-prepare node. Donor records remain US support records; the dataset is never described as New Zealand microdata. The required set is the structural and household-weight columns. Encoded engine-input columns enter only when the G4 input-closure table names them and this contract lists them, so an undeclared column fails the export gate. formula_owned_excluded is exactly the set of variables the graph requests from the bound rulespec-nz modules (axiom_rules_bindings.json): no rule output may persist as an input.", + "_description": "Closed Frame export contract for the New Zealand v0 population dataset, which the planned CLI (package G6) is to write outside the graph after the export-prepare node. Donor records remain US support records; the dataset is never described as New Zealand microdata. The required set is the structural and household-weight columns. Encoded engine-input columns enter only when the G4 input-closure table names them and this contract lists them, so an undeclared column is to fail the export gate. formula_owned_excluded is exactly the set of variables the graph requests from the bound rulespec-nz modules (axiom_rules_bindings.json): no rule output may persist as an input.", "_source": { "rules_bindings": "axiom_rules_bindings.json", "unit_rule": "benefit_unit_rule.json" diff --git a/packages/microcosm-build/src/microcosm/build/nz/release_contract.json b/packages/microcosm-build/src/microcosm/build/nz/release_contract.json index ca0ed43cec..db0690a243 100644 --- a/packages/microcosm-build/src/microcosm/build/nz/release_contract.json +++ b/packages/microcosm-build/src/microcosm/build/nz/release_contract.json @@ -50,5 +50,5 @@ "restricted": false, "notes": "The donor artifact is the immutable public Build P release pinned in source_stages.json. Chronicle owns per-source licence evidence for each NZ aggregate. Any future HES CURF, IDI, or other restricted unit-record contribution requires a separate private-repository and destination-guard review before it enters this contract." }, - "notes": "The target dataset period is tax year 2026-27, represented by 2026 in the current single-year kernel. The generic release assembler must copy release_manifest_country to release_manifest.json.country exactly. The dashboard registry reads policyengine/populace-nz/latest.json; the staging repository is for build telemetry. calibration_diagnostics.json is the published target-fit surface and reform_validation.json carries the Treasury official-score comparisons with explicit entitlement-to-operating-cost bridges. The two Hugging Face repositories and generic NZ release assembler do not yet exist, so this contract authorizes no publication by itself." + "notes": "The target dataset period is tax year 2026-27, represented by 2026 in the current single-year kernel. The generic release assembler must copy release_manifest_country to release_manifest.json.country exactly. The dashboard registry reads policyengine/populace-nz/latest.json; the staging repository is for build telemetry. calibration_diagnostics.json is the published target-fit surface. reform_validation.json stays a required release file, but its v0 contents are not yet specified: the Treasury official-score comparisons it carried in microcosm#821 came from the WFF reform checks, which this package leaves out. The two Hugging Face repositories and generic NZ release assembler do not yet exist, so this contract authorizes no publication by itself." } diff --git a/packages/microcosm-build/src/microcosm/build/nz/source_stages.json b/packages/microcosm-build/src/microcosm/build/nz/source_stages.json index 3618155c90..ca16e8c7e7 100644 --- a/packages/microcosm-build/src/microcosm/build/nz/source_stages.json +++ b/packages/microcosm-build/src/microcosm/build/nz/source_stages.json @@ -52,7 +52,7 @@ "household_weight", "age" ], - "notes": "This pins an authenticated donor artifact, not an executable NZ loader. The generic loader must preserve Frame entity ids, membership, typed household design weights, and support strata; set donor_country_code to US; refuse unreceipted aliases; and convert donor mass to a Chronicle-supplied NZ population total with a mass-conservation receipt. The typed operation vocabulary cannot represent that destination-mass bridge without inventing its still-missing target value, so execution remains blocked. In the graph build (docs/nz-take-up-engine-plan.md) the shared transport CREATE kernel performs this load, builds the benefit units of benefit_unit_rule.json, and installs design weights at the Stats NZ population scale." + "notes": "This pins an authenticated donor artifact, not an executable NZ loader. The generic loader must preserve Frame entity ids, membership, typed household design weights, and support strata; set donor_country_code to US; refuse unreceipted aliases; and convert donor mass to a Chronicle-supplied NZ population total with a mass-conservation receipt. The typed operation vocabulary cannot represent that destination-mass bridge without inventing its still-missing target value, so execution remains blocked. In the planned graph build (docs/nz-take-up-engine-plan.md) the shared transport CREATE kernel (packages G4 and G5a) is to perform this load, build the benefit units of benefit_unit_rule.json, and install design weights at the Stats NZ population scale." } ] } diff --git a/packages/microcosm-build/src/microcosm/build/nz/spec/sources.yaml b/packages/microcosm-build/src/microcosm/build/nz/spec/sources.yaml index f58c65a5fa..fb6070dd5f 100644 --- a/packages/microcosm-build/src/microcosm/build/nz/spec/sources.yaml +++ b/packages/microcosm-build/src/microcosm/build/nz/spec/sources.yaml @@ -1,9 +1,9 @@ sources: -- byte_size: 2864 +- byte_size: 2897 id: nz_populace_us_support_contract loader: kernel:load_populace_us_support_pool role: nz_populace_us_support_pool - sha256: a4e5bb6801e8f2b8bf539dcb739aaa35c4d2162909d61f18327932e7e252aae7 + sha256: cc1eef0769c79917f33c5ba0f99edf861070143168db48e0145b71efeee751d1 vintage_authorities: - id: nz_support_pool_2024_buildp kind: survey_period @@ -49,7 +49,7 @@ stages: nonnegative_outputs: - household_weight - age - notes: This pins an authenticated donor artifact, not an executable NZ loader. The generic loader must preserve Frame entity ids, membership, typed household design weights, and support strata; set donor_country_code to US; refuse unreceipted aliases; and convert donor mass to a Chronicle-supplied NZ population total with a mass-conservation receipt. The typed operation vocabulary cannot represent that destination-mass bridge without inventing its still-missing target value, so execution remains blocked. In the graph build (docs/nz-take-up-engine-plan.md) the shared transport CREATE kernel performs this load, builds the benefit units of benefit_unit_rule.json, and installs design weights at the Stats NZ population scale. + notes: This pins an authenticated donor artifact, not an executable NZ loader. The generic loader must preserve Frame entity ids, membership, typed household design weights, and support strata; set donor_country_code to US; refuse unreceipted aliases; and convert donor mass to a Chronicle-supplied NZ population total with a mass-conservation receipt. The typed operation vocabulary cannot represent that destination-mass bridge without inventing its still-missing target value, so execution remains blocked. In the planned graph build (docs/nz-take-up-engine-plan.md) the shared transport CREATE kernel (packages G4 and G5a) is to perform this load, build the benefit units of benefit_unit_rule.json, and install design weights at the Stats NZ population scale. operations: - kind: read_tables tables: diff --git a/packages/microcosm-build/tests/engine_free/shared/test_nz_spec_package.py b/packages/microcosm-build/tests/engine_free/shared/test_nz_spec_package.py index f047b894a5..fe7320b6ca 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_nz_spec_package.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_nz_spec_package.py @@ -1,18 +1,25 @@ """The New Zealand spec package, graph era: invariants over its resources. The NZ package is spec data only. These tests state the invariants the graph -build relies on and check them on the committed resources; each validator is -also exercised by Hypothesis so a vacuous pass on today's data cannot hide a +build relies on and check them on the committed resources. Each validator +also has Hypothesis cases that break its input and require a refusal, at +least one per refusal branch, so a vacuous pass on today's data cannot hide a broken check: - the three reference sets (calibration, pre-calibration, hold-out) are separate resources, every row is an unactivated placeholder, and no - hold-out fact can reach calibration; -- no resource names a rulespec-nz commit other than the one pin, which is on - rulespec-nz main; -- every crosswalk territorial authority's area shares sum to 1 within 1e-9; + calibration or pre-calibration reference shares a hold-out reference's + name, Ledger identifier or family, or has a selector that one Ledger fact + could satisfy together with a hold-out selector (``holdout_leaks``); +- no resource names a rulespec commit other than the two reviewed on + rulespec-nz main, the rules-binding pin and the commit the committed Axiom + input surface was generated at (``rulespec_commit_mentions``); +- every crosswalk territorial authority's area shares sum to 1 within 1e-9 + (``crosswalk_errors``), and the S3 modal area refuses ties + (``modal_area``); - every scenario names exactly one method-card row and a tier from - {entitlement, calibration}, and changes exactly one knob from the centre. + {entitlement, calibration}, and changes exactly one knob from the centre + (``scenario_errors``). Differential checks compare two records of one fact: the loader's target references with a direct parse, the rules bindings' module digests with the @@ -22,21 +29,24 @@ from __future__ import annotations +import copy +import dataclasses import hashlib import json import math import re -from collections.abc import Iterator, Mapping +from collections.abc import Callable, Iterator, Mapping, Sequence from typing import Any import pytest -from hypothesis import given, settings +from hypothesis import assume, given, settings from hypothesis import strategies as st from microcosm.build.country_spec import load_country_spec from microcosm.build.ledger_targets import ( LedgerTargetReference, compile_ledger_target_references, + reference_fact_selectors, ) from microcosm.build.spec_engine import load_yaml12 from test_support.paths import paths_for @@ -58,9 +68,11 @@ #: microcosm#821's WFF stage pinned. It must never re-enter the package. UNMERGED_RULESPEC_COMMIT = "3b663b3e6eb6408351154990be0c4b92d42c92da" METHOD_CARD_ROW = re.compile(r"MC(?:[1-9]|1[0-5])") +METHOD_CARD_ROWS = tuple(f"MC{number}" for number in range(1, 16)) TIERS = {"entitlement", "calibration"} SHARE_TOLERANCE = 1e-9 AREAS = {1, 2, 3, 4} +HOLDOUT_LEAK_KINDS = ("name", "ledger identifier", "ledger selector", "family") def _load(name: str) -> dict[str, Any]: @@ -113,47 +125,78 @@ def _input_surface() -> dict[str, dict[str, Any]]: # Validators (exercised by Hypothesis below, applied to the package here) # --------------------------------------------------------------------------- -_HEX = re.compile(r"(?", "rulespec-nz ", "rulespec-nz commit ``" and -#: similar prose: the repository name, then at most one connecting word. -_PROSE_MENTION = re.compile( - r"rulespec-nz(?:@|\W+(?:(?:at|commit|pin|head)\W+)?)`?([0-9a-fA-F]{7,40})(?![0-9a-fA-F])" -) +_HEX = re.compile(r"(? set[str]: - """Return every commit (7-40 hex) a payload attributes to rulespec-nz. +def _hex_runs(text: str) -> set[str]: + return {run.lower() for run in _HEX.findall(text)} + - A commit is attributed when it follows the repository name in prose - (``rulespec-nz@``, ``rulespec-nz commit ``, ``rulespec-nz - ``), or when it is a ``commit``/``rulespec_commit`` value of a - mapping whose ``repository`` names rulespec-nz or that sits under a - ``rulespec`` key. +def rulespec_commit_mentions(value: object) -> set[str]: + """Return every commit (7-40 hex) a payload may attribute to rulespec. + + The scan errs towards reporting: a reviewed commit costs nothing, and an + unreviewed one is what the scan exists to catch. A hex run of 7-40 + characters (any case) counts as a mention when it is in: + + - a string (a value or a mapping key) that contains "rulespec" in any + case, wherever in the string the run sits; + - any key or value, at any depth, below a key that contains "rulespec" + or in a rulespec record (a mapping whose ``repository`` contains + "rulespec" in any case); + - the value, at any depth, of a key that contains "commit" in any case, + unless the key's mapping is a foreign record (its ``repository`` lacks + "rulespec") and neither the key nor an enclosing key contains + "rulespec". + + A foreign record cancels the context it sits in, except that its commit + keys still count below a rulespec key. Integers count where hex strings + count: YAML reads an all-digit short SHA as an integer. A short SHA that + YAML reads as a float (``1234e56``) is not caught; quote SHAs. """ found: set[str] = set() - def visit(node: object, *, rulespec_context: bool) -> None: + def visit(node: object, *, rulespec_context: bool, commit_value: bool) -> None: if isinstance(node, Mapping): - repository = str(node.get("repository", "")) - context = rulespec_context or repository.endswith("rulespec-nz") + repository = node.get("repository") + named = isinstance(repository, str) + rulespec_record = named and "rulespec" in repository.lower() + foreign = named and not rulespec_record + if rulespec_record: + commit_value = True + elif foreign: + commit_value = False for key, child in node.items(): - if key in {"commit", "rulespec_commit"} and isinstance(child, str): - if context or key == "rulespec_commit": - found.update(_HEX.findall(child.lower())) - visit(child, rulespec_context=context or key == "rulespec") + name = str(key).lower() + commit_key = "commit" in name and (rulespec_context or not foreign) + visit( + str(key), + rulespec_context=rulespec_context, + commit_value=commit_value, + ) + visit( + child, + rulespec_context=rulespec_context or "rulespec" in name, + commit_value=commit_value or commit_key or "rulespec" in name, + ) elif isinstance(node, list): for child in node: - visit(child, rulespec_context=rulespec_context) + visit( + child, rulespec_context=rulespec_context, commit_value=commit_value + ) elif isinstance(node, str): - found.update(match.lower() for match in _PROSE_MENTION.findall(node)) + if commit_value or "rulespec" in node.lower(): + found.update(_hex_runs(node)) + elif isinstance(node, int) and not isinstance(node, bool) and commit_value: + found.update(_hex_runs(str(node))) - visit(value, rulespec_context=False) + visit(value, rulespec_context=False, commit_value=False) return found def reviewed_rulespec_commits() -> set[str]: - """Full rulespec-nz commits reviewed as on its main branch. + """Full rulespec-nz commits reviewed as on its main branch: there are two. The rules-binding pin (verified with ``git branch -r --contains`` when it was set) and the commit the committed Axiom input surface was generated @@ -175,14 +218,120 @@ def unreviewed_mentions(mentions: set[str], reviewed: set[str]) -> set[str]: } +#: Selector fields that name one Ledger fact. The compiler resolves a +#: reference's ledger_fact_key and ledger_source_record_id through one index +#: over a fact's aggregate, semantic, plain and legacy fact keys and its source +#: record id (ledger_targets._ledger_fact_index), so identifiers are compared +#: across fields. +_IDENTIFIER_SELECTOR_FIELDS = ( + "aggregate_fact_key", + "semantic_fact_key", + "legacy_fact_key", + "source_record_id", +) +#: Selector fields matched as structures; here they never rule out an overlap. +_STRUCTURED_SELECTOR_FIELDS = {"dimensions", "dimension_values"} + + +def _selector_values(value: object) -> frozenset[str] | None: + """The values a selector field admits, or None when it admits any. + + Mirrors ledger_targets._fact_matches_selector: an empty value is a + wildcard and a list matches by membership. + """ + + if value is None or value == "": + return None + if isinstance(value, (list, tuple)): + return frozenset(str(item) for item in value) + return frozenset({str(value)}) + + +def _ledger_identifiers(reference: LedgerTargetReference) -> set[str]: + selector = reference.ledger_selector + fields = [reference.ledger_fact_key, reference.ledger_source_record_id] + fields += [selector.get(field) for field in _IDENTIFIER_SELECTOR_FIELDS] + return {value for field in fields for value in _selector_values(field) or ()} + + +def _selectors_overlap( + first: Mapping[str, object], second: Mapping[str, object] +) -> bool: + """Whether one Ledger fact could satisfy both selectors. + + Every field both selectors pin must admit a common value. A field that + either side leaves out or empty rules nothing out. This assumes a fact's + alternative fields for one selector key agree: the compiler reads + source_measure_id, for one, from two places in a fact. + """ + + if not first or not second: + return False + for field in set(first) & set(second) - _STRUCTURED_SELECTOR_FIELDS: + admitted = _selector_values(first[field]), _selector_values(second[field]) + if None not in admitted and not admitted[0] & admitted[1]: + return False + return True + + +def holdout_leaks( + cal: Sequence[LedgerTargetReference], + precal: Sequence[LedgerTargetReference], + holdout: Sequence[LedgerTargetReference], +) -> list[str]: + """Name the routes from a hold-out fact into calibration or pre-calibration. + + These are the routes the references themselves show. An upstream + (calibration or pre-calibration) reference leaks when it: + + - has a hold-out reference's name; + - shares a Ledger identifier with one, in any of the identifier fields; + - resolves through a selector (its own, or a scaled_by_ratio operand's, + per ledger_targets.reference_fact_selectors) that one Ledger fact + could satisfy together with a hold-out reference's selector; + - belongs to a family the hold-out set covers. + + Sharing only a source is not a leak when both selectors pin different + measures: one source publishes many facts. Without the Ledger feed this + cannot match an identifier on one side against a selector on the other; + the graph-level ancestry test (package G6) is to cover that. + """ + + names = {reference.name for reference in holdout} + identifiers = { + identifier + for reference in holdout + for identifier in _ledger_identifiers(reference) + } + families = {reference.family for reference in holdout} + leaks: list[str] = [] + for reference_set, references in (("calibration", cal), ("precal", precal)): + for reference in references: + label = f"{reference_set} reference {reference.name!r}" + if reference.name in names: + leaks.append(f"{label}: hold-out name {reference.name!r}") + for identifier in sorted(_ledger_identifiers(reference) & identifiers): + leaks.append(f"{label}: hold-out ledger identifier {identifier!r}") + for held in holdout: + if any( + _selectors_overlap(selector, held_selector) + for selector in reference_fact_selectors(reference) + for held_selector in reference_fact_selectors(held) + ): + leaks.append(f"{label}: hold-out ledger selector of {held.name!r}") + if reference.family in families: + leaks.append(f"{label}: hold-out family {reference.family!r}") + return leaks + + def crosswalk_errors(rows: list[Mapping[str, Any]]) -> list[str]: """Name every crosswalk row that breaks the share contract.""" errors: list[str] = [] seen: set[str] = set() for row in rows: - code = str(row.get("ta_code", "")) - if not code: + code = row.get("ta_code") + if not isinstance(code, str) or not code: errors.append("row without ta_code") continue if code in seen: @@ -270,7 +419,12 @@ def scenario_errors(document: Mapping[str, Any]) -> list[str]: elif value is None: if "status" not in scenario: errors.append(f"{label}: a pending value needs a status") - elif not (isinstance(value, (int, float)) and value > 0): + elif not ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and value > 0 + ): errors.append(f"{label}: {knob!r} needs a positive number") return errors @@ -280,6 +434,107 @@ def scenario_errors(document: Mapping[str, Any]) -> list[str]: # --------------------------------------------------------------------------- +_IDENT = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789_", min_size=1, max_size=24 +) + + +#: Every place a reference can carry a Ledger identifier. +_IDENTIFIER_SLOTS = ( + "ledger_fact_key", + "ledger_source_record_id", + *(f"ledger_selector.{field}" for field in _IDENTIFIER_SELECTOR_FIELDS), +) +#: Ways an upstream selector can reach the fact a hold-out selector names. +_OVERLAPPING_SELECTORS = { + "same": lambda selector: dict(selector), + "measure in a list": lambda selector: { + **selector, + "source_measure_id": ["another_measure", selector["source_measure_id"]], + }, + "measure left empty": lambda selector: {**selector, "source_measure_id": ""}, + "source only": lambda selector: {"source_name": selector["source_name"]}, + "no geography": lambda selector: { + field: value for field, value in selector.items() if field != "geography_level" + }, +} + +_Plant = Callable[[LedgerTargetReference], LedgerTargetReference] + + +def _with_identifier( + reference: LedgerTargetReference, slot: str, identifier: str +) -> LedgerTargetReference: + if slot.startswith("ledger_selector."): + field = slot.removeprefix("ledger_selector.") + selector = {**reference.ledger_selector, field: identifier} + return dataclasses.replace(reference, ledger_selector=selector) + return dataclasses.replace(reference, **{slot: identifier}) + + +def _as_ratio_numerator( + reference: LedgerTargetReference, numerator: Mapping[str, object] +) -> LedgerTargetReference: + """``reference`` as a scaled_by_ratio whose numerator reads ``numerator``.""" + + operands = ( + {"role": "base"}, + {"role": "numerator", **numerator}, + {"role": "denominator", "source_name": "unrelated_source"}, + ) + return dataclasses.replace( + reference, value_operation="scaled_by_ratio", value_operands=operands + ) + + +def _fresh_hold_out_and_plant( + held: LedgerTargetReference, kind: str, identity: str, data: st.DataObject +) -> tuple[LedgerTargetReference, _Plant]: + """Give ``held`` a fresh ``kind`` identity; return it and its upstream plant.""" + + if kind == "name": + return ( + dataclasses.replace(held, name=identity), + lambda reference: dataclasses.replace(reference, name=identity), + ) + if kind == "ledger identifier": + held_slot, planted_slot = ( + data.draw(st.sampled_from(_IDENTIFIER_SLOTS)) for _ in range(2) + ) + return ( + _with_identifier(held, held_slot, identity), + lambda reference: _with_identifier(reference, planted_slot, identity), + ) + if kind == "ledger selector": + selector = { + **held.ledger_selector, + "source_name": f"{identity}_source", + "source_measure_id": f"{identity}_measure", + } + variant = data.draw(st.sampled_from(sorted(_OVERLAPPING_SELECTORS))) + overlapping = _OVERLAPPING_SELECTORS[variant](selector) + route = data.draw(st.sampled_from(["own", "upstream ratio", "hold-out ratio"])) + if route == "hold-out ratio": + # The hold-out reads the fresh fact as a scaled_by_ratio numerator. + return ( + _as_ratio_numerator(held, selector), + lambda reference: dataclasses.replace( + reference, ledger_selector=overlapping + ), + ) + held = dataclasses.replace(held, ledger_selector=selector) + if route == "upstream ratio": + return held, lambda reference: _as_ratio_numerator(reference, overlapping) + return held, lambda reference: dataclasses.replace( + reference, ledger_selector=overlapping + ) + assert kind == "family" + return ( + dataclasses.replace(held, family=identity), + lambda reference: dataclasses.replace(reference, family=identity), + ) + + class TestReferenceSets: @pytest.mark.parametrize("reference_set", sorted(REFERENCE_FILES)) def test_each_set_is_a_self_identifying_nz_reference_resource( @@ -297,6 +552,11 @@ def test_each_set_is_a_self_identifying_nz_reference_resource( assert {row["metadata"]["reference_set"] for row in rows} == {reference_set} for row in rows: assert METHOD_CARD_ROW.fullmatch(row["metadata"]["method_card_row"]) + # The declared operations hold row by row, so every fact a reference + # reads comes through its own selector or identifiers. + for reference in _references(reference_set): + assert reference.value_operation == "identity" + assert reference.value_operands == () @pytest.mark.parametrize("reference_set", sorted(REFERENCE_FILES)) def test_every_reference_is_a_placeholder_the_compiler_refuses( @@ -356,25 +616,49 @@ def test_holdout_set_is_validation_only(self) -> None: assert reference.family in HELD_OUT_FAMILIES def test_no_held_out_fact_reaches_calibration_or_pre_calibration(self) -> None: - def selectors(reference_set: str) -> set[tuple[str, str]]: - return { - ( - str(reference.ledger_selector["source_name"]), - str(reference.ledger_selector["source_measure_id"]), - ) - for reference in _references(reference_set) - } - held = _references("holdout") upstream = _references("calibration") + _references("precal") - assert {reference.name for reference in held}.isdisjoint( - reference.name for reference in upstream - ) - assert selectors("holdout").isdisjoint( - selectors("calibration") | selectors("precal") + assert {reference.family for reference in held} == HELD_OUT_FAMILIES + # The AS recipients hold-out and the main-benefit calibration row share + # the source msd_quarterly_benefit_facts but not a measure, so they + # name different facts (the plan's "filter by fact key"). + assert ( + holdout_leaks(_references("calibration"), _references("precal"), held) == [] ) assert HELD_OUT_FAMILIES.isdisjoint(reference.family for reference in upstream) + @settings(max_examples=300, deadline=None) + @given( + data=st.data(), + kind=st.sampled_from(HOLDOUT_LEAK_KINDS), + target=st.sampled_from(("calibration", "precal")), + identity=_IDENT, + insert=st.booleans(), + ) + def test_a_hold_out_identity_planted_upstream_is_refused( + self, data, kind: str, target: str, identity: str, insert: bool + ) -> None: + sets = {name: _references(name) for name in REFERENCE_FILES} + held = data.draw(st.integers(0, len(sets["holdout"]) - 1)) + sets["holdout"][held], plant = _fresh_hold_out_and_plant( + sets["holdout"][held], kind, identity, data + ) + # A fresh identity: before the plant, no upstream reference carries it. + assume(not holdout_leaks(sets["calibration"], sets["precal"], sets["holdout"])) + upstream = sets[target] + if insert: + donor = data.draw(st.sampled_from(upstream)) + position = data.draw(st.integers(0, len(upstream))) + upstream.insert(position, plant(donor)) + else: + position = data.draw(st.integers(0, len(upstream) - 1)) + upstream[position] = plant(upstream[position]) + leaks = holdout_leaks(sets["calibration"], sets["precal"], sets["holdout"]) + assert any( + leak.startswith(f"{target} ") and f": hold-out {kind} " in leak + for leak in leaks + ) + def test_a_reference_in_two_upstream_sets_names_one_fact(self) -> None: calibration = {row["name"]: row for row in _rows("calibration")} shared = [row for row in _rows("precal") if row["name"] in calibration] @@ -417,6 +701,22 @@ def test_gate_profile_requires_exactly_the_calibration_families(self) -> None: _FILLER = st.text(alphabet=st.characters(blacklist_categories=("Cs",)), max_size=20) #: A suffix that cannot extend the planted hex run (41 hex digits is no commit). _SUFFIX = _FILLER.filter(lambda text: text[:1].lower() not in set("0123456789abcdef")) +#: Prose between "rulespec" and a planted commit, of any length, with no hex +#: digit that could extend the planted run. +_GAP = st.text( + alphabet="ghijklmnopqrstuvwxyzGHIJKLMNOPQRSTUVWXYZ '`-_@:/.,()#", + min_size=1, + max_size=300, +) +_KEY_AFFIX = st.text(alphabet="abcdefghijklmnopqrstuvwxyz_-.", max_size=12) +#: Spellings of the rulespec-nz repository a record may carry. +_RULESPEC_REPOSITORIES = ( + "TheAxiomFoundation/rulespec-nz", + "TheAxiomFoundation/rulespec-nz.git", + "https://github.com/TheAxiomFoundation/rulespec-nz/", + "https://github.com/TheAxiomFoundation/rulespec-nz/tree/main", + "TheAxiomFoundation/RuleSpec-NZ", +) class TestRulespecPin: @@ -483,6 +783,146 @@ def test_scanner_catches_the_821_wording(self) -> None: mentions = rulespec_commit_mentions({"status": wording}) assert unreviewed_mentions(mentions, reviewed_rulespec_commits()) == {"3b663b3"} + def test_scanner_catches_the_phrasings_the_reviews_planted(self) -> None: + # Each edit passed an earlier version of the scanner: the first two in + # the microcosm#1119 review (finding 3), the last three in the review + # of its fix-up. + payloads = _package_payloads() + payloads["spec/bundle.yaml"]["status"] += ( + " Uses rulespec-nz's commit deadbeefcafe1." + ) + payloads["currency_bridge.json"]["rulespec_nz_commit"] = "deadbeef" * 5 + surface = payloads["axiom_rules_bindings.json"]["input_surface"] + assert "git diff 6fe181fc 8dc2507" in surface["notes"] + surface["notes"] = surface["notes"].replace( + "git diff 6fe181fc 8dc2507", "git diff 6fe181fc c0ffee1" + ) + payloads["currency_bridge.json"]["source"]["rules"] = { + "repository": "TheAxiomFoundation/rulespec-nz.git", + "revision": "facade9876543", + } + payloads["scenarios.json"]["rulespec"] = {"head": "abad1dea"} + mentions = rulespec_commit_mentions(payloads) + assert unreviewed_mentions(mentions, reviewed_rulespec_commits()) == { + "deadbeefcafe1", + "deadbeef" * 5, + "c0ffee1", + "facade9876543", + "abad1dea", + } + + @settings(max_examples=300, deadline=None) + @given( + commit=_HEX40_TEXT, + length=st.integers(7, 40), + gap=_GAP, + before=st.booleans(), + upper=st.booleans(), + anchor=st.sampled_from(["rulespec", "Rulespec", "RuleSpec", "RULESPEC"]), + ) + def test_any_hex_run_in_a_string_that_names_rulespec_is_a_mention( + self, + commit: str, + length: int, + gap: str, + before: bool, + upper: bool, + anchor: str, + ) -> None: + short = commit[:length] + planted = short.upper() if upper else short + text = f"{planted}{gap}{anchor}" if before else f"{anchor}{gap}{planted}" + assert short in rulespec_commit_mentions({"notes": text}) + assert short in rulespec_commit_mentions({text: "a mapping key"}) + + @settings(max_examples=200, deadline=None) + @given(commit=_HEX40_TEXT, length=st.integers(7, 40), filler=_FILLER) + def test_a_string_that_does_not_name_rulespec_is_not_a_mention( + self, commit: str, length: int, filler: str + ) -> None: + text = f"{filler}{commit[:length]}{filler}" + assume("rulespec" not in text.lower()) + assert rulespec_commit_mentions({"notes": text}) == set() + + @settings(max_examples=200, deadline=None) + @given( + commit=_HEX40_TEXT, + length=st.integers(7, 40), + repository=st.sampled_from(_RULESPEC_REPOSITORIES), + key=st.sampled_from(["commit", "revision", "sha", "ref", "head", "pinned"]), + ) + def test_every_hex_value_of_a_rulespec_record_is_a_mention( + self, commit: str, length: int, repository: str, key: str + ) -> None: + short = commit[:length] + record = {"repository": repository, key: short} + assert rulespec_commit_mentions({"source": record}) == {short} + assert rulespec_commit_mentions({"rulespec": {key: short}}) == {short} + # A value straight under a rulespec key, as a string or in a list. + for rulespec_key in ("rulespec", f"rulespec_{key}"): + assert rulespec_commit_mentions({rulespec_key: short}) == {short} + assert rulespec_commit_mentions({rulespec_key: [short]}) == {short} + + @settings(max_examples=200, deadline=None) + @given(digits=st.integers(10**6, 10**40 - 1)) + def test_an_all_digit_short_sha_that_yaml_reads_as_an_integer_counts( + self, digits: int + ) -> None: + payload = load_yaml12(f"rulespec_nz_commit: {digits}\n", source="t.yaml") + assert payload == {"rulespec_nz_commit": digits} + assert rulespec_commit_mentions(payload) == {str(digits)} + + @settings(max_examples=200, deadline=None) + @given(commit=_HEX40_TEXT, length=st.integers(7, 40)) + def test_a_hex_mapping_key_under_a_commit_key_is_a_mention( + self, commit: str, length: int + ) -> None: + short = commit[:length] + assert short in rulespec_commit_mentions({"reviewed_commits": {short: "main"}}) + + @settings(max_examples=300, deadline=None) + @given( + commit=_HEX40_TEXT, + length=st.integers(7, 40), + prefix=_KEY_AFFIX, + suffix=_KEY_AFFIX, + upper=st.booleans(), + shape=st.sampled_from(["string", "list", "mapping"]), + nest=st.integers(0, 3), + ) + def test_hex_under_any_commit_key_outside_a_foreign_record_is_a_mention( + self, + commit: str, + length: int, + prefix: str, + suffix: str, + upper: bool, + shape: str, + nest: int, + ) -> None: + short = commit[:length] + key = f"{prefix}{'COMMIT' if upper else 'commit'}{suffix}" + value = {"string": short, "list": [short], "mapping": {"sha": short}}[shape] + planted: object = {key: value} + for depth in range(nest): + planted = {"level": depth, "pins": [planted]} + assert short in rulespec_commit_mentions(planted) + + @settings(max_examples=200, deadline=None) + @given(commit=_HEX40_TEXT, prefix=_KEY_AFFIX, suffix=_KEY_AFFIX) + def test_a_foreign_record_keeps_its_commits_unless_the_key_names_rulespec( + self, commit: str, prefix: str, suffix: str + ) -> None: + key = f"{prefix}commit{suffix}" + assume("rulespec" not in key) + record = {"repository": "TheAxiomFoundation/ops", key: commit} + assert rulespec_commit_mentions(record) == set() + # It stays foreign under another commit key, but not under rulespec. + assert rulespec_commit_mentions({"pinned_commits": [record]}) == set() + assert rulespec_commit_mentions({"rulespec": record}) == {commit} + record["rulespec_nz_commit"] = commit + assert rulespec_commit_mentions(record) == {commit} + # --------------------------------------------------------------------------- # Rules bindings and the reg 17/18 bridge @@ -630,6 +1070,93 @@ def _crosswalk_rows(draw) -> list[dict[str, Any]]: return rows +# Each breaks one valid crosswalk row in one way and returns the exact error +# crosswalk_errors must report. The share-sum branch has its own drift test. +_Breaker = Callable[[list[dict[str, Any]], st.DataObject], str] +_MISSING = object() + + +def _pick_row(rows: list[dict[str, Any]], data: st.DataObject) -> dict[str, Any]: + return rows[data.draw(st.integers(0, len(rows) - 1))] + + +def _blank_ta_code(rows, data) -> str: + row = _pick_row(rows, data) + blank = data.draw(st.sampled_from([_MISSING, "", None, 0, 42])) + if blank is _MISSING: + del row["ta_code"] + else: + row["ta_code"] = blank + return "row without ta_code" + + +def _duplicate_ta(rows, data) -> str: + row = _pick_row(rows, data) + rows.insert(data.draw(st.integers(0, len(rows))), copy.deepcopy(row)) + return f"{row['ta_code']}: duplicate territorial authority" + + +def _empty_shares(rows, data) -> str: + row = _pick_row(rows, data) + shares = data.draw(st.sampled_from([_MISSING, [], None, {}, "1", 1])) + if shares is _MISSING: + del row["shares"] + else: + row["shares"] = shares + return f"{row['ta_code']}: no shares" + + +def _non_integer_area(rows, data) -> str: + row = _pick_row(rows, data) + share = data.draw(st.sampled_from(row["shares"])) + share["as_area"] = data.draw(st.sampled_from([True, False, None, "1", 1.0, 2.5])) + return f"{row['ta_code']}: area must be an integer" + + +def _repeated_area(rows, data) -> str: + row = _pick_row(rows, data) + index = data.draw(st.integers(0, len(row["shares"]) - 1)) + share = row["shares"][index] + half = share["population_share"] / 2 + # Split one share in two under the same area, so the sum still holds. + row["shares"][index : index + 1] = [ + {**share, "population_share": half}, + {**share, "population_share": share["population_share"] - half}, + ] + return f"{row['ta_code']}: repeated area" + + +def _area_outside_one_to_four(rows, data) -> str: + row = _pick_row(rows, data) + share = data.draw(st.sampled_from(row["shares"])) + share["as_area"] = data.draw(st.integers().filter(lambda area: area not in AREAS)) + return f"{row['ta_code']}: area outside 1-4" + + +def _share_outside_unit_interval(rows, data) -> str: + row = _pick_row(rows, data) + share = data.draw(st.sampled_from(row["shares"])) + share["population_share"] = data.draw( + st.one_of( + st.floats(max_value=0, exclude_max=True), + st.floats(min_value=1, exclude_min=True), + st.sampled_from([math.nan, None, "0.5", True]), + ) + ) + return f"{row['ta_code']}: share outside [0, 1]" + + +CROSSWALK_BREAKERS: dict[str, _Breaker] = { + "row without ta_code": _blank_ta_code, + "duplicate territorial authority": _duplicate_ta, + "no shares": _empty_shares, + "area must be an integer": _non_integer_area, + "repeated area": _repeated_area, + "area outside 1-4": _area_outside_one_to_four, + "share outside [0, 1]": _share_outside_unit_interval, +} + + class TestCrosswalk: def test_committed_rows_satisfy_the_share_contract(self) -> None: document = _load("as_area_crosswalk.json") @@ -679,6 +1206,42 @@ def test_boolean_areas_are_refused(self, rows) -> None: rows[0]["shares"][0]["as_area"] = True assert any("integer" in error for error in crosswalk_errors(rows)) + @pytest.mark.parametrize("branch", sorted(CROSSWALK_BREAKERS)) + @settings(max_examples=100, deadline=None) + @given(rows=_crosswalk_rows(), data=st.data()) + def test_every_refusal_branch_names_the_broken_row( + self, branch: str, rows, data + ) -> None: + expected = CROSSWALK_BREAKERS[branch](rows, data) + assert expected in crosswalk_errors(rows) + + @settings(max_examples=200, deadline=None) + @given(data=st.data()) + def test_a_tied_modal_area_refuses(self, data) -> None: + areas = data.draw( + st.lists( + st.sampled_from(sorted(AREAS)), min_size=2, max_size=4, unique=True + ) + ) + weights = data.draw( + st.lists(st.floats(1e-6, 1e6), min_size=len(areas), max_size=len(areas)) + ) + first, second = data.draw( + st.lists( + st.integers(0, len(areas) - 1), min_size=2, max_size=2, unique=True + ) + ) + weights[first] = weights[second] = max(weights) + total = math.fsum(weights) + shares = [ + {"as_area": area, "population_share": weight / total} + for area, weight in zip(areas, weights, strict=True) + ] + # A tie is a valid crosswalk row; only the S3 alternative refuses it. + assert crosswalk_errors([{"ta_code": "001", "shares": shares}]) == [] + with pytest.raises(ValueError, match="tied modal areas"): + modal_area(shares) + @settings(max_examples=200, deadline=None) @given(_crosswalk_rows()) def test_modal_alternative_is_a_listed_area_or_refuses(self, rows) -> None: @@ -699,6 +1262,134 @@ def test_modal_alternative_is_a_listed_area_or_refuses(self, rows) -> None: # --------------------------------------------------------------------------- +# Each breaks the committed grid in one way and returns the exact error +# scenario_errors must report. The method-card-row, tier and two-knob branches +# have their own tests below. +_ScenarioBreaker = Callable[[dict[str, Any], st.DataObject], str] + + +def _changed_scenario(document, data) -> dict[str, Any]: + return data.draw(st.sampled_from([s for s in document["scenarios"] if s["knobs"]])) + + +def _open_knobs(document) -> list[str]: + """Knobs whose domain is a description rather than a list of values.""" + + return sorted( + knob + for knob, domain in document["knob_domains"].items() + if not isinstance(domain, list) + ) + + +def _duplicate_scenario_id(document, data) -> str: + first, second = data.draw( + st.lists( + st.integers(0, len(document["scenarios"]) - 1), + min_size=2, + max_size=2, + unique=True, + ) + ) + document["scenarios"][second]["id"] = document["scenarios"][first]["id"] + return "duplicate scenario ids" + + +def _knobs_not_a_mapping(document, data) -> str: + scenario = data.draw(st.sampled_from(document["scenarios"])) + scenario["knobs"] = data.draw( + st.sampled_from([None, [], ["asset_test"], "asset_test", 1]) + ) + return f"{scenario['id']}: knobs must be a mapping" + + +def _central_scenario_off_the_grid_row(document, data) -> str: + scenario = data.draw(st.sampled_from(document["scenarios"])) + scenario["knobs"] = {} + scenario["method_card_row"] = data.draw( + st.sampled_from( + [row for row in METHOD_CARD_ROWS if row != document["method_card_row"]] + ) + ) + return f"{scenario['id']}: the central scenario names the grid row" + + +def _unknown_knob(document, data) -> str: + scenario = _changed_scenario(document, data) + (value,) = scenario["knobs"].values() + name = data.draw(_IDENT.filter(lambda name: name not in document["central_knobs"])) + scenario["knobs"] = {name: value} + return f"{scenario['id']}: unknown knob {name!r}" + + +def _knob_at_its_central_value(document, data) -> str: + scenario = _changed_scenario(document, data) + (knob,) = scenario["knobs"] + scenario["knobs"][knob] = document["central_knobs"][knob] + return f"{scenario['id']}: knob {knob!r} equals the central value" + + +def _knob_under_another_method_card_row(document, data) -> str: + scenario = _changed_scenario(document, data) + (knob,) = scenario["knobs"] + owner = document["knob_method_card_rows"][knob] + scenario["method_card_row"] = data.draw( + st.sampled_from([row for row in METHOD_CARD_ROWS if row != owner]) + ) + return f"{scenario['id']}: knob {knob!r} belongs to {owner}" + + +def _value_outside_a_listed_domain(document, data) -> str: + scenario = _changed_scenario(document, data) + listed = sorted(set(document["knob_domains"]) - set(_open_knobs(document))) + knob = data.draw(st.sampled_from(listed)) + domain = document["knob_domains"][knob] + value = data.draw( + st.one_of(st.text(max_size=16), st.integers(), st.none(), st.booleans()).filter( + lambda value: value not in domain + ) + ) + scenario["knobs"] = {knob: value} + scenario["method_card_row"] = document["knob_method_card_rows"][knob] + return f"{scenario['id']}: {value!r} outside the {knob!r} domain" + + +def _pending_value_without_a_status(document, data) -> str: + scenario = _changed_scenario(document, data) + knob = data.draw(st.sampled_from(_open_knobs(document))) + scenario["knobs"] = {knob: None} + scenario.pop("status", None) + return f"{scenario['id']}: a pending value needs a status" + + +def _open_knob_not_a_positive_number(document, data) -> str: + scenario = _changed_scenario(document, data) + knob = data.draw(st.sampled_from(_open_knobs(document))) + scenario["knobs"] = { + knob: data.draw( + st.one_of( + st.floats(max_value=0), + st.integers(max_value=0), + st.sampled_from([math.inf, math.nan, True, "1.5", [1.5]]), + ) + ) + } + return f"{scenario['id']}: {knob!r} needs a positive number" + + +SCENARIO_BREAKERS: dict[str, _ScenarioBreaker] = { + "duplicate scenario ids": _duplicate_scenario_id, + "knobs must be a mapping": _knobs_not_a_mapping, + "the central scenario names the grid row": _central_scenario_off_the_grid_row, + "unknown knob": _unknown_knob, + "equals the central value": _knob_at_its_central_value, + "belongs to another method-card row": _knob_under_another_method_card_row, + "outside a listed domain": _value_outside_a_listed_domain, + "a pending value needs a status": _pending_value_without_a_status, + "needs a positive number": _open_knob_not_a_positive_number, +} + + class TestScenarios: def test_committed_grid_satisfies_the_scenario_contract(self) -> None: document = _load("scenarios.json") @@ -772,6 +1463,16 @@ def test_a_scenario_changing_two_knobs_is_refused(self, data) -> None: scenario["knobs"][other] = document["central_knobs"][other] assert any("exactly one knob" in error for error in scenario_errors(document)) + @pytest.mark.parametrize("branch", sorted(SCENARIO_BREAKERS)) + @settings(max_examples=100, deadline=None) + @given(data=st.data()) + def test_every_refusal_branch_names_the_broken_scenario( + self, branch: str, data + ) -> None: + document = _load("scenarios.json") + expected = SCENARIO_BREAKERS[branch](document, data) + assert expected in scenario_errors(document) + # --------------------------------------------------------------------------- # Remaining resources diff --git a/packages/microcosm-build/tests/golden/nz_country_spec.json b/packages/microcosm-build/tests/golden/nz_country_spec.json index adc7f40194..2f0ff95ad4 100644 --- a/packages/microcosm-build/tests/golden/nz_country_spec.json +++ b/packages/microcosm-build/tests/golden/nz_country_spec.json @@ -1,6 +1,6 @@ { "country": "nz", - "fingerprint": "5077683cacb37805e9047fec3ccbaf9bddbb156c410bb41339e360f2107644bb", + "fingerprint": "df8850d5893e9311882573ff6b4baa41c9dda41f6838d9a140199ddf0cc7fd48", "gate_ids": [ "calibration_per_family_fit", "national_and_region_admin_aggregates", @@ -35,23 +35,23 @@ }, "resource_hashes": { "as_area_crosswalk.json": "cfb76c99898204598b0609e5462b2fba9d081872a13ac5dd973cb406bb6f7469", - "as_rate_bridge.json": "deffa41a6bbb8453df0e63d2bf28573da2e280a33b18ad46642ad35442b67049", - "axiom_rules_bindings.json": "ac2f2fda757ab5ac9ff284b1f9dfea712d2811dc5b289ed5c810d35527acdc02", - "benefit_unit_rule.json": "65c7c8ec9bd9d1ae11e680bf331a17a8f521185c0bb4ac487e53979fe5062220", + "as_rate_bridge.json": "5eea897159508fb71820f22cd8ccf4993075a750462bd97312d9e89ab01cba15", + "axiom_rules_bindings.json": "72905af77132ff6a3c01ada4b6210be4c369d9c6dc49dc8c1b673e4bac0b5fb1", + "benefit_unit_rule.json": "9e507e78f180566a1eece74da2a35d88478dbe13487c3a64ee2b76c9ecc7a653", "country_package.json": "b83de9e12238b41481810ddb7fbdbaaa71d457cbfa9da83f3f53cbf479cee43b", - "currency_bridge.json": "b364db79565e2fba51160bcf5489612b81b51b0aeb7ae569a908a748815281ce", - "export_contract.json": "1a66f9f8bc820a28b6b8c04d3f5b1ebf43902e9903b698590000264745d98c39", + "currency_bridge.json": "aa6a4b286a6a1a015088f32ac013148553422c3d45eeef7d7a15af08388857ca", + "export_contract.json": "f0629a3990a47f3f94114c4d7b6680636ef7b1bd34bb8e950976d3065f029fda", "gates.json": "3d709d88bd40198b61b9a1745b6f4481625145aa13a7414d700726c50159a0ae", "geography_spine.json": "353821844d85d2622c328b4986dd9e3a8075c13498b9f56d349c4578edf7495a", "holdout_references.json": "52b5a2360502156da1392a68350e4e7f61b5fd420fa74428819b65886a9f8655", "precal_references.json": "43b13849e3d74ebca7d7a9d46817463e3ad1333c3aedd7a3eac39e870510fba0", - "release_contract.json": "c0acc8122acf90e7a1d0bcb363ac95dd92928d9f521608525f729df10de421a2", + "release_contract.json": "6cd5e3a4a2bf30e4025e04e964c393b24040937d13db7e6a07b3966a55f3b114", "scenarios.json": "5dbf2b9603fa55fd9abd7319e188f882ffe471e99e7d691b81980522a9bef82f", - "source_stages.json": "a4e5bb6801e8f2b8bf539dcb739aaa35c4d2162909d61f18327932e7e252aae7", + "source_stages.json": "cc1eef0769c79917f33c5ba0f99edf861070143168db48e0145b71efeee751d1", "spec/bundle.yaml": "25ef74ecbc00c3f2ecca2d30ddd5e46a582657863c217da33ee9a396569d2cf2", "spec/catalogs.yaml": "3ab2025af39cba93ead707b9463e428430a45b30696a43d0906cbcd97ed36631", "spec/geography.yaml": "0dbf9f850dc3cef983f166bf6783022094894ec2461ad71f4809607bcfd390a9", - "spec/sources.yaml": "5e94c8f46996e48cc02f92881b57a540aec3893643363b254cda1b5e18c42525", + "spec/sources.yaml": "6ce8861328eff3b70b787570d1150b0c636269ba4c4f8583052b9ed3ad9ed523", "spec/spine.yaml": "b3e6a2832ff7671777cae113b7fc45f5e3492bb519526f229118d0851083533e", "spec/vintages.yaml": "1ecc1a3f952bd25b834c394314e8c933ddb5b095fd64ba492dddb03e3cdd803e", "target_references.json": "5cc916bc477e250f562469aaf2d144693fa3aa2a7824cc11096ad35b7c13b543"