Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/nz-as-bridge-review-follow-up.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Correct the NZ Accommodation Supplement bridge's documentation and tests after the review of #1440. `docs/transport-entitlement.md` now names `nz.as.bridge` as the shared population that runs the masks, gross rate and cutout for S0, S1, S2, S3 and S5, and the entitlement generator's docstring drops the sentence that contradicted it. `as_rate_bridge.json` now states why the youngest-child override takes a mask: a null age would reach the engine as NaN. `benefit_unit_rule.json` now says the youngest-child age column is null for a childless family, as `unit_construction.py` writes it. The childless-family tests read the Axiom adapter's own batch builder, and a new test runs a mixed couple through S5's base rate.
15 changes: 9 additions & 6 deletions docs/transport-entitlement.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,15 +178,18 @@ does this with its invented family-credit engine. It supplies no real WFF
inputs or statutory claim.

`build/nz/as_rate_bridge.json` declares both NZ bridge outputs as kernel
calls, with no new kernel. On each population that runs the Accommodation
Supplement engine, a `calibration.prepare@1` node writes the family masks the
equality-only bridge masks compare, a `simulate.counterfactual@1` node writes
the reg 18(3)(b) sole-parent Jobseeker Support rate, and a
calls, with no new kernel. A `calibration.prepare@1` node writes the family
masks the equality-only bridge masks compare, a `simulate.counterfactual@1`
node writes the reg 18(3)(b) sole-parent Jobseeker Support rate, and a
`simulate.solve_zero@1` node writes the reg 18 cutout: the zero of each
unit's Jobseeker Support residual over weekly income on [0, 5000]. A sole
parent's residual is the sole-parent rate minus the Income Test 3 reduction;
every other unit's residual is its own net payment. The resource keeps the
harness expressions as the statement of equivalence. Each branch runs its own
every other unit's residual is its own net payment. The shared `nz.as.bridge`
population, a FILTER branch of `nz.as`, runs these three nodes once for S0,
S1, S2, S3 and S5, and each variant repeats them on its own housing
population. A node on `nz.as` itself would re-key the skeleton's
`nz.terminal`, which composition refuses. The resource keeps the harness
expressions as the statement of equivalence. Each branch runs its own
reg 17 base-rate counterfactual: zero-income Jobseeker Support once for a
single unit and twice for a partnered one, plus the eldest-child family tax
credit over 52 for a unit with a dependent child. The tests in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
"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). The unit column is null for a childless family, and the engine adapter reads integer inputs as int64, so the override applies only where the unit has a dependent child; other rows keep the closure default 0.",
"reason": "harness: family_shape youngest_child_age (run.py:1938,1951). The unit column is null for a childless family, and that null would reach the engine input as NaN, so the override applies only where the unit has a dependent child; other rows keep the closure default 0.",
"mask": {
"unit_attribute": "family.accommodation_supplement_bridge_has_dependent_child",
"equals": true
Expand Down Expand Up @@ -285,7 +285,7 @@
"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). The unit column is null for a childless family, and the engine adapter reads integer inputs as int64, so the override applies only where the unit has a dependent child; other rows keep the closure default 0.",
"reason": "harness: family_shape youngest_child_age (run.py:1938,1951). The unit column is null for a childless family, and that null would reach the engine input as NaN, so the override applies only where the unit has a dependent child; other rows keep the closure default 0.",
"mask": {
"unit_attribute": "family.accommodation_supplement_bridge_has_dependent_child",
"equals": true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"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"
"family.youngest_dependent_child_age": "age of the youngest dependent child; null when there is none"
}
},
"settled_date": "2026-10-12",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from microcosm.build.transport.entitlement import _parameter, _rows
from microcosm.build.transport.target_kernels import CalibrationPrepareKernel
from microcosm.frame import ExportContract, Frame, VariableMetadata, WeightKind, Weights
from microcosm.frame.adapters.axiom import NZ_SCHEMA
from microcosm.frame.adapters.axiom import NZ_SCHEMA, _batch_from_table
from microcosm.frame.rules_kernels import (
SimulateCounterfactualKernel,
SimulateSolveZeroKernel,
Expand Down Expand Up @@ -373,6 +373,7 @@ class Unit:
adult_age: int = 30
cash_assets: float = 0.0
rent: float = 400.0
partner_age: int | None = None


_RECEIPT_FLAGS = {
Expand Down Expand Up @@ -414,7 +415,12 @@ def _frame(units):
adults = 2 if unit.partnered else 1
for member in range(adults + len(unit.children)):
adult = member < adults
age = unit.adult_age if adult else unit.children[member - adults]
if not adult:
age = unit.children[member - adults]
elif member == 1 and unit.partner_age is not None:
age = unit.partner_age
else:
age = unit.adult_age
row = {
"person_id": next_person,
"person_household_id": family_id,
Expand Down Expand Up @@ -724,28 +730,28 @@ def _youngest_received(units, *, masked=True):
_run(frame, row, scenario, engines)
finally:
received = engines[_REFS["main_benefits_rates"]].received
return received[-1][_YOUNGEST]
table = received[-1][[_YOUNGEST]]
return _batch_from_table(table, [_YOUNGEST])[_YOUNGEST]


def test_a_childless_family_keeps_the_closure_default_youngest_child_age():
units = [Unit(), Unit(children=(4, 7))]
received = _youngest_received(units)
# 0 for the childless family's adult, 4 for the parent and both children.
assert pd.Series(received).to_numpy(dtype=np.int64).tolist() == [0, 4, 4, 4]
assert received.dtype == np.float64
assert received.tolist() == [0.0, 4.0, 4.0, 4.0]


def test_unmasked_youngest_child_override_carries_a_missing_age_to_the_engine():
"""Without the mask the childless unit's null age reaches the engine input.

pandas 3 reads the nullable Int64 source as float64 with NaN, so the
adapter's int64 conversion raises under numpy's invalid-value check and
otherwise yields int64's minimum.
pandas 3 reads the nullable Int64 source as float64 with NaN, so the adapter
takes its float branch and hands the engine NaN for that adult.
"""
units = [Unit(), Unit(children=(4, 7))]
received = _youngest_received(units, masked=False)
assert received.isna().tolist() == [True, False, False, False]
with np.errstate(invalid="raise"), pytest.raises(FloatingPointError):
pd.Series(received).to_numpy(dtype=np.int64)
assert received.dtype == np.float64
assert np.isnan(received).tolist() == [True, False, False, False]


def test_bridge_scratch_columns_stay_out_of_the_export_contract():
Expand All @@ -761,7 +767,7 @@ def test_bridge_scratch_columns_stay_out_of_the_export_contract():
assert not persisted & (scratch | {_GROSS})


def test_every_as_population_runs_its_own_bridge_and_every_branch_its_base_rate():
def test_each_bridge_population_runs_the_bridge_and_every_branch_its_base_rate():
rows = {row["id"]: row for row, _ in _rows(_SPEC)[0]}
scenarios = _SPEC["resources"]["scenarios"]["scenarios"]
for node_id in ("masks", "sole_parent_gross_rate", "cutout"):
Expand Down Expand Up @@ -926,6 +932,25 @@ def test_a_partnered_superannuitant_unit_takes_coefficient_two_in_s5():
assert abs(base[101] - (SuperannuationEngine.weekly + credit)) <= 1e-9


@pytest.mark.parametrize(("first_age", "second_age"), [(70, 50), (50, 70)])
def test_a_mixed_couple_in_s5_takes_the_superannuitants_amount_for_both_partners(
first_age, second_age
):
# The fixture pays the partner under 65 nothing, so ``max`` over members
# gives the superannuitant's amount in either person order.
base = _s5_base_rate(
[
Unit(
receipt_group="superannuitant",
adult_age=first_age,
partner_age=second_age,
partnered=True,
)
]
)
assert base[100] == 2 * SuperannuationEngine.weekly


def test_the_central_base_rate_keeps_jobseeker_for_superannuitant_units():
_, _, base, _ = _bridge(
[Unit(receipt_group="superannuitant", adult_age=70, partnered=True)]
Expand Down
6 changes: 3 additions & 3 deletions packages/microcosm-build/tests/golden/nz_country_spec.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"country": "nz",
"fingerprint": "fc9e3f65841862016207a1baa368e8ecf0476d194675ad8ec771811a5ea54f4f",
"fingerprint": "4d6be0d9d5adc1ecc1c65a04c3fa97baa646665eeb9439b6a8100642a7723f79",
"gate_ids": [
"calibration_per_family_fit",
"demography_per_family_fit",
Expand Down Expand Up @@ -40,12 +40,12 @@
"resource_hashes": {
"as_area_crosswalk.json": "56eeb638d77c73fd92ca541d9d2f249fd3062bece0503470e0e85dde70c4c05b",
"as_encoding.json": "91e0e9972abfaaa202a13ede6e2dbc815a4c6c9514726c970928a47adfef0e32",
"as_rate_bridge.json": "25fa43210fd91655988933fe23ac6411bc30ddeb73c480c24da19999ffa2da22",
"as_rate_bridge.json": "1399321cc7ef44486f7ab407968b036930ff6ae8fbab4c30ce710ea0fff7301c",
"atomic_geography_support.json": "6efd09d5cde2f16d4d01e522a721191a829a754d2ce019d75bb0407085ecb4b7",
"axiom_input_closure.json": "1c237a316eecffd8d8eb9258a451b992373b804d72b411895337647c272731aa",
"axiom_rules_bindings.json": "fb163e4842c8a910d63bd0ecf0fc81e5a55a2dddaa9cde66a52d9f4ff2c791f3",
"benefit_gross_up.json": "bb0100927c3d666f497b24694109463de247d53d6e7ee0cfa092255e33c5514e",
"benefit_unit_rule.json": "a9e8cbfd38a64d2f8db0002fe8c31bb5a18fb34dbe59fa943336487ce95e2ca8",
"benefit_unit_rule.json": "c84efba8353c770131256a8c17b82c7d98f738e6dd8ef217e19d30014cb14604",
"calibration_bindings.json": "abf5278b51a13530e2c589a932942a185f5dda095c1eb3e4bc703999c24852b3",
"calibration_settings.json": "56e4d1da8468245da6bd41daf263425cc65e4e9c9f11ff45eee459d3aed1989e",
"chronicle_feed.json": "60a80dd0042838b0412baf9dd0985c961b4253a6876d16bcdbb8a2c6c03de0c7",
Expand Down
20 changes: 10 additions & 10 deletions tools/build_nz_entitlement_graph.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
"""Generate NZ's post-calibration entitlement branch declarations.

The Accommodation Supplement bridge follows ``as_rate_bridge.json``. On each
population that runs the AS engine, a ``calibration.prepare@1`` node writes
the family masks, a ``simulate.counterfactual@1`` node writes the reg 18(3)(b)
sole-parent gross rate and a ``simulate.solve_zero@1`` node writes the reg 18
cutout. The shared ``nz.as.bridge`` population, a FILTER branch of ``nz.as``,
runs these once for every entitlement scenario, and each variant repeats them
on its own housing population. An extension node on ``nz.as`` itself would
re-key the skeleton's ``nz.terminal``, which composition refuses. Each
branch runs its own reg 17 base-rate counterfactual. Every coefficient, mask
and override comes from the resource; this generator adds no schedule.
The Accommodation Supplement bridge follows ``as_rate_bridge.json``. A
``calibration.prepare@1`` node writes the family masks, a
``simulate.counterfactual@1`` node writes the reg 18(3)(b) sole-parent gross
rate and a ``simulate.solve_zero@1`` node writes the reg 18 cutout. The shared
``nz.as.bridge`` population, a FILTER branch of ``nz.as``, runs these once for
every entitlement scenario, and each variant repeats them on its own housing
population. An extension node on ``nz.as`` itself would re-key the skeleton's
``nz.terminal``, which composition refuses. Each branch runs its own reg 17
base-rate counterfactual. Every bridge coefficient, mask and override comes
from the resource; this generator adds no schedule.
"""

from __future__ import annotations
Expand Down
Loading