From 65a1f2694a89f25976e0228f0ce0b6ab12855ebb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 3 Jul 2026 00:39:53 +0200 Subject: [PATCH 1/2] Add Belgium pilot: Axiom rules engine over populace-be entity tables The first non-policyengine-core country channel: an Axiom-backed TaxBenefitModelVersion outside the certified-release machinery, running the rulespec-be composed worker pipeline (employee SSC and PIT before withholding) over populace-be person/household tables with calibrated weights. Includes a populace-style two-entity dataset class, a population example scored against ONSS/SPF facts, and tests that skip cleanly when the source-only dependencies are absent. Fixes #447 Co-Authored-By: Claude Fable 5 --- changelog.d/447.added.md | 1 + examples/belgium_axiom_pilot.py | 59 +++++++ .../tax_benefit_models/be/__init__.py | 29 ++++ .../tax_benefit_models/be/datasets.py | 49 ++++++ .../tax_benefit_models/be/model.py | 158 ++++++++++++++++++ tests/test_be_axiom_pilot.py | 96 +++++++++++ 6 files changed, 392 insertions(+) create mode 100644 changelog.d/447.added.md create mode 100644 examples/belgium_axiom_pilot.py create mode 100644 src/policyengine/tax_benefit_models/be/__init__.py create mode 100644 src/policyengine/tax_benefit_models/be/datasets.py create mode 100644 src/policyengine/tax_benefit_models/be/model.py create mode 100644 tests/test_be_axiom_pilot.py diff --git a/changelog.d/447.added.md b/changelog.d/447.added.md new file mode 100644 index 00000000..a95863ab --- /dev/null +++ b/changelog.d/447.added.md @@ -0,0 +1 @@ +Belgium pilot: an Axiom-rules-engine-backed model version (tax_benefit_models/be) running the rulespec-be worker pipeline over populace-be entity tables, with a two-entity dataset class, example, and source-dependency-gated tests. diff --git a/examples/belgium_axiom_pilot.py b/examples/belgium_axiom_pilot.py new file mode 100644 index 00000000..918b32be --- /dev/null +++ b/examples/belgium_axiom_pilot.py @@ -0,0 +1,59 @@ +"""Belgium population microsimulation through the Axiom rules engine. + +Runs the calibrated populace-be pilot dataset (populace-us support records +reweighted to Statbel/SPF/ONSS/ONEM targets from PolicyEngine/ledger) +through the rulespec-be composed worker pipeline, and scores the aggregates +against Belgian administrative facts. + +This is a demonstration of the engine channel, not a certified Belgian +model: the support records are American, and only worker SSC and individual +PIT are encoded so far. + +Usage:: + + POPULACE_BE_DATASET=.../populace_be_pilot_2026.h5 \\ + RULESPEC_BE_ROOT=.../rulespec-be \\ + uv run python examples/belgium_axiom_pilot.py +""" + +import os + +from policyengine.core.simulation import Simulation +from policyengine.tax_benefit_models.be import ( + EMPLOYEE_SSC, + PIT_BEFORE_WITHHOLDING, + AxiomBelgiumPilot, + PopulaceBelgiumDataset, +) + +DATASET = os.environ["POPULACE_BE_DATASET"] +RULESPEC = os.environ.get("RULESPEC_BE_ROOT", "~/TheAxiomFoundation/rulespec-be") + +# Ledger facts (PolicyEngine/ledger, Belgian publisher packages) +ONSS_WORKER_CONTRIBUTIONS_2024 = 20_836_582_673 +SPF_PIT_BEFORE_WITHHOLDING_2023 = 62_840_116_134 + +dataset = PopulaceBelgiumDataset( + name="populace-be-pilot", + description="populace-us support reweighted to Belgian ledger targets", + filepath=DATASET, + year=2026, +) +model_version = AxiomBelgiumPilot(rulespec_root=RULESPEC, period=2025) + +simulation = Simulation(dataset=dataset, tax_benefit_model_version=model_version) +simulation.run() + +person = simulation.output_dataset.data.person +ssc = person[EMPLOYEE_SSC].sum() +pit = person[PIT_BEFORE_WITHHOLDING].sum() + +print("Belgium pilot (Axiom engine over populace-be, worker slice)") +print(f" employee SSC EUR {ssc / 1e9:6.2f}B (ONSS 2024: EUR 20.84B)") +print( + f" PIT before withholding EUR {pit / 1e9:6.2f}B (SPF 2023, all PIT: EUR 62.84B)" +) +print(f" SSC vs ONSS ratio {ssc / ONSS_WORKER_CONTRIBUTIONS_2024:.3f}") +print( + f" PIT vs SPF ratio {pit / SPF_PIT_BEFORE_WITHHOLDING_2023:.3f} (worker slice only)" +) diff --git a/src/policyengine/tax_benefit_models/be/__init__.py b/src/policyengine/tax_benefit_models/be/__init__.py new file mode 100644 index 00000000..d05692ae --- /dev/null +++ b/src/policyengine/tax_benefit_models/be/__init__.py @@ -0,0 +1,29 @@ +"""Belgium pilot: Axiom rules engine over populace entity tables. + +The first non-policyengine-core country in policyengine.py. Statutes are +encoded as RuleSpec YAML (TheAxiomFoundation/rulespec-be), compiled and +executed by axiom-rules-engine, and driven over populace-be entity tables. +See ``examples/belgium_axiom_pilot.py`` for the end-to-end population run +and ``model.py`` for scope and source-install requirements. +""" + +from .datasets import BEYearData, PopulaceBelgiumDataset +from .model import ( + EMPLOYEE_SSC, + PIT_BEFORE_WITHHOLDING, + REMUNERATION, + AxiomBelgium, + AxiomBelgiumPilot, + be_model, +) + +__all__ = [ + "AxiomBelgium", + "AxiomBelgiumPilot", + "BEYearData", + "EMPLOYEE_SSC", + "PIT_BEFORE_WITHHOLDING", + "PopulaceBelgiumDataset", + "REMUNERATION", + "be_model", +] diff --git a/src/policyengine/tax_benefit_models/be/datasets.py b/src/policyengine/tax_benefit_models/be/datasets.py new file mode 100644 index 00000000..0a28b8d8 --- /dev/null +++ b/src/policyengine/tax_benefit_models/be/datasets.py @@ -0,0 +1,49 @@ +"""Belgium pilot dataset: populace entity tables with calibrated weights. + +The pilot layout has two entities (person, household), mirroring the +populace ``BE_SCHEMA``. Files are plain pandas HDF5 stores with ``person`` +and ``household`` keys; weights live in ``person_weight`` / +``household_weight`` columns, as in the US/UK single-year layouts. +""" + +from typing import Any, Optional + +import pandas as pd +from microdf import MicroDataFrame +from pydantic import ConfigDict, Field + +from policyengine.core.dataset import Dataset, YearData + + +class BEYearData(YearData): + """Entity-level data for a single Belgian year.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + person: MicroDataFrame + household: MicroDataFrame + + @property + def entity_data(self) -> dict[str, MicroDataFrame]: + return {"person": self.person, "household": self.household} + + +class PopulaceBelgiumDataset(Dataset): + """Belgium pilot dataset loaded from a populace-be HDF5 artifact.""" + + data: Optional[BEYearData] = None + metadata: dict[str, Any] = Field(default_factory=dict) + + def load(self) -> None: + person = pd.read_hdf(self.filepath, key="person") + household = pd.read_hdf(self.filepath, key="household") + self.data = BEYearData( + person=MicroDataFrame(person, weights="person_weight"), + household=MicroDataFrame(household, weights="household_weight"), + ) + + def save(self) -> None: + if self.data is None: + raise ValueError("No data to save.") + pd.DataFrame(self.data.person).to_hdf(self.filepath, key="person", mode="w") + pd.DataFrame(self.data.household).to_hdf(self.filepath, key="household") diff --git a/src/policyengine/tax_benefit_models/be/model.py b/src/policyengine/tax_benefit_models/be/model.py new file mode 100644 index 00000000..036a5472 --- /dev/null +++ b/src/policyengine/tax_benefit_models/be/model.py @@ -0,0 +1,158 @@ +"""Axiom-backed Belgium pilot model version. + +Unlike the US and UK model versions, Belgium runs on the Axiom rules +engine: statutes encoded as RuleSpec YAML in TheAxiomFoundation/rulespec-be, +compiled and executed by ``axiom-rules-engine``, and driven over populace +entity tables through the ``populace-frame`` Axiom adapter. There is no +policyengine-core country package and no certified release manifest, so this +version subclasses ``TaxBenefitModelVersion`` directly and stays outside the +managed-release machinery. + +Requirements (neither is on PyPI yet): + +- ``populace-frame`` from PolicyEngine/populace (``packages/populace-frame``) +- ``axiom-rules-engine`` from TheAxiomFoundation/axiom-rules-engine (PyO3 + dense extension) +- a checkout of TheAxiomFoundation/rulespec-be, passed as ``rulespec_root`` + +Scope: the composed worker pipeline only — employee social security +contributions (13.07 percent ordinary worker contribution) and personal +income tax before withholding for wage earners under individual assessment. +Dependants, joint assessment, other income categories, and employment tax +reductions are not yet encoded (TheAxiomFoundation/rulespec-be#1). +""" + +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, Optional, Union + +import pandas as pd +from microdf import MicroDataFrame + +from policyengine.core import TaxBenefitModel +from policyengine.core.tax_benefit_model_version import TaxBenefitModelVersion + +from .datasets import BEYearData, PopulaceBelgiumDataset + +if TYPE_CHECKING: + from policyengine.core.simulation import Simulation + +PILOT_MODULE = "be/statutes/income_tax/individual/pilot_worker_oracle_pipeline.yaml" +REMUNERATION = "belgium_pit_article_23_worker_remuneration" +EMPLOYEE_SSC = "belgium_employee_social_security_ordinary_worker_contribution" +PIT_BEFORE_WITHHOLDING = "belgium_pit_pilot_federal_and_local_tax_before_withholding" + +#: Pipeline inputs the pilot supplies as scalars when the dataset does not +#: carry them (rulespec stage boundaries are supplied inputs by convention). +SUPPLIED_DEFAULTS: dict[str, Union[float, bool]] = { + "belgium_pit_article_466_tax_share_on_nonprofessional_movable_income": 0.0, + "belgium_pit_article_466bis_hypothetical_total_tax_if_treaty_exempt_foreign_professional_income_were_belgian": 0.0, + "belgium_pit_article_466bis_treaty_exempt_foreign_professional_income_base_applies": False, + "belgium_pit_pilot_article_289ter1_work_bonus_a_amount": 0.0, + "belgium_pit_pilot_article_289ter1_work_bonus_b_amount": 0.0, + "belgium_pit_communal_additional_tax_rate": 0.0, + "belgium_pit_agglomeration_additional_tax_rate": 0.0, +} + + +class AxiomBelgium(TaxBenefitModel): + id: str = "axiom-rulespec-be" + description: str = ( + "Belgium tax rules encoded as RuleSpec (TheAxiomFoundation/rulespec-be), " + "executed by the Axiom rules engine over populace entity tables." + ) + + +be_model = AxiomBelgium() + + +class AxiomBelgiumPilot(TaxBenefitModelVersion): + """Pilot Belgium model version: worker SSC and PIT via Axiom.""" + + country_code: ClassVar[str] = "be" + + rulespec_root: str + period: Optional[int] = None + output_variables: list[str] = [EMPLOYEE_SSC, PIT_BEFORE_WITHHOLDING] + communal_additional_tax_rate: float = 0.0 + + def __init__(self, **kwargs) -> None: + kwargs.setdefault("model", be_model) + kwargs.setdefault("version", "0.1.0-pilot") + super().__init__(**kwargs) + + def run(self, simulation: "Simulation") -> "Simulation": + try: + from populace.frame import Frame, WeightKind, Weights + from populace.frame.adapters.axiom import BE_SCHEMA, AxiomEngine + except ImportError as error: + raise ImportError( + "The Belgium pilot needs populace-frame (PolicyEngine/populace, " + "packages/populace-frame) and axiom-rules-engine " + "(TheAxiomFoundation/axiom-rules-engine); neither is on PyPI " + "yet, install both from source." + ) from error + + module = Path(self.rulespec_root).expanduser() / PILOT_MODULE + if not module.exists(): + raise FileNotFoundError( + f"rulespec-be pilot module not found at {module}; pass a " + "checkout of TheAxiomFoundation/rulespec-be as rulespec_root." + ) + + dataset = simulation.dataset + assert isinstance(dataset, PopulaceBelgiumDataset) + if dataset.data is None: + dataset.load() + assert dataset.data is not None + + person = pd.DataFrame(dataset.data.person).copy() + household = pd.DataFrame(dataset.data.household).copy() + for name, value in SUPPLIED_DEFAULTS.items(): + if name not in person.columns: + person[name] = value + person["belgium_pit_communal_additional_tax_rate"] = ( + self.communal_additional_tax_rate + ) + + weights = { + "household": Weights( + values=household["household_weight"].to_numpy(), + kind=WeightKind.CALIBRATED, + ) + } + # The frame kernel owns weight columns (typed Weights vectors); + # they stay on the pe.py-side MicroDataFrames only. + frame = Frame( + { + "person": person.drop(columns=["person_weight"]), + "household": household.drop(columns=["household_weight"]), + }, + BE_SCHEMA, + weights, + ) + engine = AxiomEngine(str(module)) + outputs = engine.materialize( + frame, self.output_variables, self.period or dataset.year + ) + for name, values in outputs.items(): + person[name] = values + + simulation.output_dataset = PopulaceBelgiumDataset( + id=simulation.id, + name=dataset.name, + description=dataset.description, + filepath=dataset.filepath, + year=dataset.year, + is_output_dataset=True, + data=BEYearData( + person=MicroDataFrame(person, weights="person_weight"), + household=MicroDataFrame(household, weights="household_weight"), + ), + ) + return simulation + + def save(self, simulation: "Simulation") -> None: + """Pilot simulations are recomputed, not persisted.""" + + def load(self, simulation: "Simulation") -> None: + raise FileNotFoundError("Pilot simulations are recomputed, not persisted.") diff --git a/tests/test_be_axiom_pilot.py b/tests/test_be_axiom_pilot.py new file mode 100644 index 00000000..364bff98 --- /dev/null +++ b/tests/test_be_axiom_pilot.py @@ -0,0 +1,96 @@ +"""Belgium pilot: Axiom engine over a tiny populace-style dataset. + +Skips cleanly unless the source-only dependencies (populace-frame, +axiom-rules-engine) are importable and a rulespec-be checkout is available +via ``RULESPEC_BE_ROOT`` (or the default sibling path). +""" + +import os +from importlib.util import find_spec +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +RULESPEC_ROOT = Path( + os.environ.get("RULESPEC_BE_ROOT", "~/TheAxiomFoundation/rulespec-be") +).expanduser() +PILOT_MODULE = ( + RULESPEC_ROOT + / "be/statutes/income_tax/individual/pilot_worker_oracle_pipeline.yaml" +) + +requires_axiom = pytest.mark.skipif( + find_spec("populace") is None + or find_spec("axiom_rules_engine") is None + or not PILOT_MODULE.exists(), + reason="needs populace-frame, axiom-rules-engine, and a rulespec-be checkout", +) + +ORDINARY_WORKER_SSC_RATE = 0.1307 # arrete royal 28.11.1969, art. 19 + + +@pytest.fixture +def pilot_dataset(tmp_path): + from policyengine.tax_benefit_models.be import PopulaceBelgiumDataset + + person = pd.DataFrame( + { + "person_id": [1, 2, 3], + "person_household_id": [1, 1, 2], + "age": [40.0, 38.0, 30.0], + "is_male": [True, False, False], + "belgium_pit_article_23_worker_remuneration": [0.0, 30_000.0, 60_000.0], + "person_weight": [1.0, 1.0, 1.0], + } + ) + household = pd.DataFrame({"household_id": [1, 2], "household_weight": [1.0, 1.0]}) + path = tmp_path / "populace_be_test.h5" + person.to_hdf(path, key="person", mode="w") + household.to_hdf(path, key="household") + return PopulaceBelgiumDataset( + name="populace-be-test", + description="three-person fixture", + filepath=str(path), + year=2026, + ) + + +@requires_axiom +def test_pilot_run_computes_statutory_ssc_and_progressive_pit(pilot_dataset): + from policyengine.core.simulation import Simulation + from policyengine.tax_benefit_models.be import ( + EMPLOYEE_SSC, + PIT_BEFORE_WITHHOLDING, + REMUNERATION, + AxiomBelgiumPilot, + ) + + version = AxiomBelgiumPilot(rulespec_root=str(RULESPEC_ROOT), period=2025) + simulation = Simulation(dataset=pilot_dataset, tax_benefit_model_version=version) + simulation.run() + + person = pd.DataFrame(simulation.output_dataset.data.person) + gross = person[REMUNERATION].to_numpy() + ssc = person[EMPLOYEE_SSC].to_numpy() + pit = person[PIT_BEFORE_WITHHOLDING].to_numpy() + + np.testing.assert_allclose(ssc, gross * ORDINARY_WORKER_SSC_RATE, rtol=1e-9) + assert pit[0] == 0.0 + assert 0.0 < pit[1] < pit[2] + assert simulation.output_dataset.is_output_dataset + + +@requires_axiom +def test_pilot_weighted_aggregates_use_calibrated_weights(pilot_dataset): + from policyengine.core.simulation import Simulation + from policyengine.tax_benefit_models.be import EMPLOYEE_SSC, AxiomBelgiumPilot + + version = AxiomBelgiumPilot(rulespec_root=str(RULESPEC_ROOT), period=2025) + simulation = Simulation(dataset=pilot_dataset, tax_benefit_model_version=version) + simulation.run() + + person = simulation.output_dataset.data.person + expected = 90_000.0 * ORDINARY_WORKER_SSC_RATE + assert float(person[EMPLOYEE_SSC].sum()) == pytest.approx(expected, rel=1e-9) From 2bebb239af59e4da226f5664ff7fbc57dedfa575 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 3 Jul 2026 07:47:17 +0200 Subject: [PATCH 2/2] Track rulespec-be work-bonus encoding in the Belgium pilot The composed pipeline now computes the ONSS work-bonus amounts from law (the a/b supplied inputs are gone; the reference-wage input bridges 0 to worker remuneration), and the ordinary-contribution concept nets the bonus. Update the supplied defaults, exercise the phase-out in the test fixture (wiped at 20k, partial at 30k, statutory 13.07 percent by 60k), and label the example aggregates accordingly. Co-Authored-By: Claude Fable 5 --- examples/belgium_axiom_pilot.py | 18 +++--- .../tax_benefit_models/be/model.py | 5 +- tests/test_be_axiom_pilot.py | 63 ++++++++++++------- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/examples/belgium_axiom_pilot.py b/examples/belgium_axiom_pilot.py index 918b32be..4d26bd48 100644 --- a/examples/belgium_axiom_pilot.py +++ b/examples/belgium_axiom_pilot.py @@ -6,8 +6,9 @@ against Belgian administrative facts. This is a demonstration of the engine channel, not a certified Belgian -model: the support records are American, and only worker SSC and individual -PIT are encoded so far. +model: the support records are American, and coverage is the worker slice +(employee SSC with the statutory low-wage work bonus, and individual PIT +before withholding with the fiscal work-bonus credit). Usage:: @@ -45,15 +46,18 @@ simulation.run() person = simulation.output_dataset.data.person -ssc = person[EMPLOYEE_SSC].sum() +ssc_net = person[EMPLOYEE_SSC].sum() pit = person[PIT_BEFORE_WITHHOLDING].sum() print("Belgium pilot (Axiom engine over populace-be, worker slice)") -print(f" employee SSC EUR {ssc / 1e9:6.2f}B (ONSS 2024: EUR 20.84B)") +print(f" employee SSC after work bonus EUR {ssc_net / 1e9:6.2f}B") +print(" (ONSS 2024 contributions: EUR 20.84B; the bonus is outsized here") +print(" because the US-support wage distribution is low-wage-heavy)") print( - f" PIT before withholding EUR {pit / 1e9:6.2f}B (SPF 2023, all PIT: EUR 62.84B)" + f" PIT before withholding EUR {pit / 1e9:6.2f}B " + "(SPF 2023, all PIT: EUR 62.84B)" ) -print(f" SSC vs ONSS ratio {ssc / ONSS_WORKER_CONTRIBUTIONS_2024:.3f}") print( - f" PIT vs SPF ratio {pit / SPF_PIT_BEFORE_WITHHOLDING_2023:.3f} (worker slice only)" + f" PIT vs SPF ratio {pit / SPF_PIT_BEFORE_WITHHOLDING_2023:.3f}" + " (worker slice only)" ) diff --git a/src/policyengine/tax_benefit_models/be/model.py b/src/policyengine/tax_benefit_models/be/model.py index 036a5472..a19f4ba7 100644 --- a/src/policyengine/tax_benefit_models/be/model.py +++ b/src/policyengine/tax_benefit_models/be/model.py @@ -43,12 +43,13 @@ #: Pipeline inputs the pilot supplies as scalars when the dataset does not #: carry them (rulespec stage boundaries are supplied inputs by convention). +#: The work-bonus reference wage bridges 0 -> worker remuneration inside the +#: pipeline, so the scalar default keeps the statutory low-wage bonus active. SUPPLIED_DEFAULTS: dict[str, Union[float, bool]] = { "belgium_pit_article_466_tax_share_on_nonprofessional_movable_income": 0.0, "belgium_pit_article_466bis_hypothetical_total_tax_if_treaty_exempt_foreign_professional_income_were_belgian": 0.0, "belgium_pit_article_466bis_treaty_exempt_foreign_professional_income_base_applies": False, - "belgium_pit_pilot_article_289ter1_work_bonus_a_amount": 0.0, - "belgium_pit_pilot_article_289ter1_work_bonus_b_amount": 0.0, + "belgium_worker_work_bonus_supplied_reference_annual_remuneration": 0.0, "belgium_pit_communal_additional_tax_rate": 0.0, "belgium_pit_agglomeration_additional_tax_rate": 0.0, } diff --git a/tests/test_be_axiom_pilot.py b/tests/test_be_axiom_pilot.py index 364bff98..d95ba26f 100644 --- a/tests/test_be_axiom_pilot.py +++ b/tests/test_be_axiom_pilot.py @@ -29,6 +29,10 @@ ) ORDINARY_WORKER_SSC_RATE = 0.1307 # arrete royal 28.11.1969, art. 19 +# incomes chosen around the 2025 work-bonus phase-out: the low-wage bonus +# wipes the employee contribution at 20k, partially reduces it at 30k, and +# is exhausted well before 60k (ONSS DMFA 2025 tables, as encoded). +INCOMES = [0.0, 20_000.0, 30_000.0, 60_000.0] @pytest.fixture @@ -37,12 +41,12 @@ def pilot_dataset(tmp_path): person = pd.DataFrame( { - "person_id": [1, 2, 3], - "person_household_id": [1, 1, 2], - "age": [40.0, 38.0, 30.0], - "is_male": [True, False, False], - "belgium_pit_article_23_worker_remuneration": [0.0, 30_000.0, 60_000.0], - "person_weight": [1.0, 1.0, 1.0], + "person_id": [1, 2, 3, 4], + "person_household_id": [1, 1, 2, 2], + "age": [40.0, 38.0, 30.0, 52.0], + "is_male": [True, False, False, True], + "belgium_pit_article_23_worker_remuneration": INCOMES, + "person_weight": [1.0, 1.0, 1.0, 1.0], } ) household = pd.DataFrame({"household_id": [1, 2], "household_weight": [1.0, 1.0]}) @@ -51,46 +55,57 @@ def pilot_dataset(tmp_path): household.to_hdf(path, key="household") return PopulaceBelgiumDataset( name="populace-be-test", - description="three-person fixture", + description="four-person fixture around the work-bonus phase-out", filepath=str(path), year=2026, ) -@requires_axiom -def test_pilot_run_computes_statutory_ssc_and_progressive_pit(pilot_dataset): +def _run(pilot_dataset): from policyengine.core.simulation import Simulation + from policyengine.tax_benefit_models.be import AxiomBelgiumPilot + + version = AxiomBelgiumPilot(rulespec_root=str(RULESPEC_ROOT), period=2025) + simulation = Simulation(dataset=pilot_dataset, tax_benefit_model_version=version) + simulation.run() + return simulation + + +@requires_axiom +def test_pilot_run_computes_ssc_with_work_bonus_and_progressive_pit(pilot_dataset): from policyengine.tax_benefit_models.be import ( EMPLOYEE_SSC, PIT_BEFORE_WITHHOLDING, REMUNERATION, - AxiomBelgiumPilot, ) - version = AxiomBelgiumPilot(rulespec_root=str(RULESPEC_ROOT), period=2025) - simulation = Simulation(dataset=pilot_dataset, tax_benefit_model_version=version) - simulation.run() - + simulation = _run(pilot_dataset) person = pd.DataFrame(simulation.output_dataset.data.person) gross = person[REMUNERATION].to_numpy() ssc = person[EMPLOYEE_SSC].to_numpy() pit = person[PIT_BEFORE_WITHHOLDING].to_numpy() - np.testing.assert_allclose(ssc, gross * ORDINARY_WORKER_SSC_RATE, rtol=1e-9) + statutory = gross * ORDINARY_WORKER_SSC_RATE + assert ssc[0] == 0.0 + assert ssc[1] == 0.0 # work bonus wipes the contribution at 20k + assert 0.0 < ssc[2] < statutory[2] # partial bonus at 30k + np.testing.assert_allclose(ssc[3], statutory[3], rtol=1e-9) # exhausted + assert pit[0] == 0.0 - assert 0.0 < pit[1] < pit[2] + assert 0.0 <= pit[1] <= pit[2] < pit[3] assert simulation.output_dataset.is_output_dataset @requires_axiom def test_pilot_weighted_aggregates_use_calibrated_weights(pilot_dataset): - from policyengine.core.simulation import Simulation - from policyengine.tax_benefit_models.be import EMPLOYEE_SSC, AxiomBelgiumPilot - - version = AxiomBelgiumPilot(rulespec_root=str(RULESPEC_ROOT), period=2025) - simulation = Simulation(dataset=pilot_dataset, tax_benefit_model_version=version) - simulation.run() + from policyengine.tax_benefit_models.be import EMPLOYEE_SSC + simulation = _run(pilot_dataset) person = simulation.output_dataset.data.person - expected = 90_000.0 * ORDINARY_WORKER_SSC_RATE - assert float(person[EMPLOYEE_SSC].sum()) == pytest.approx(expected, rel=1e-9) + total = float(person[EMPLOYEE_SSC].sum()) + # 0 + 0 (bonus-wiped) + partial at 30k + full 13.07% at 60k + assert ( + 60_000.0 * ORDINARY_WORKER_SSC_RATE + < total + < 90_000.0 * ORDINARY_WORKER_SSC_RATE + )