diff --git a/changelog.d/nz-as-bridge-review-follow-up.fixed.md b/changelog.d/nz-as-bridge-review-follow-up.fixed.md new file mode 100644 index 0000000000..3b3a693992 --- /dev/null +++ b/changelog.d/nz-as-bridge-review-follow-up.fixed.md @@ -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. diff --git a/docs/transport-entitlement.md b/docs/transport-entitlement.md index 0fbf6315b7..4f67cb24a4 100644 --- a/docs/transport-entitlement.md +++ b/docs/transport-entitlement.md @@ -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 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 f278805131..57e543c382 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 @@ -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 @@ -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 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 bf3be694eb..6476c0ea08 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 @@ -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", diff --git a/packages/microcosm-build/tests/engine_free/shared/test_nz_as_bridge.py b/packages/microcosm-build/tests/engine_free/shared/test_nz_as_bridge.py index 5673c99b77..37127e3369 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_nz_as_bridge.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_nz_as_bridge.py @@ -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, @@ -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 = { @@ -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, @@ -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(): @@ -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"): @@ -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)] diff --git a/packages/microcosm-build/tests/golden/nz_country_spec.json b/packages/microcosm-build/tests/golden/nz_country_spec.json index 8318fd8570..23a3bc995c 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": "fc9e3f65841862016207a1baa368e8ecf0476d194675ad8ec771811a5ea54f4f", + "fingerprint": "4d6be0d9d5adc1ecc1c65a04c3fa97baa646665eeb9439b6a8100642a7723f79", "gate_ids": [ "calibration_per_family_fit", "demography_per_family_fit", @@ -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", diff --git a/tools/build_nz_entitlement_graph.py b/tools/build_nz_entitlement_graph.py index e371567160..3161833993 100644 --- a/tools/build_nz_entitlement_graph.py +++ b/tools/build_nz_entitlement_graph.py @@ -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