From b3e2716343d6eecc20af6689e1e11129eafade0f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:45:30 -0400 Subject: [PATCH 1/2] Guard against person variables that add group variables policyengine-core evaluates adds/subtracts with calculate(..., map_to=), and map_result projects a group value onto every member with no split. A person variable that lists a tax unit or SPM unit variable therefore gives each member the whole group amount, and a group total counts it once per member (MT casualty loss #9888, MS HSA #9865, WV senior #9856, and OH, IL, CA Riverside and LA cases found the same day). The new code-health test scans the shared baseline system, resolving parameter-path lists across their whole history. Three boolean enrollment flags are allowed; the 10 current cases are known bugs that may only be removed. A premise test pins the projection on core's template country. Co-Authored-By: Claude Opus 5.5 --- ...th-person-adds-group-projection.changed.md | 1 + .../test_no_group_variables_in_person_adds.py | 293 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 changelog.d/code-health-person-adds-group-projection.changed.md create mode 100644 policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py diff --git a/changelog.d/code-health-person-adds-group-projection.changed.md b/changelog.d/code-health-person-adds-group-projection.changed.md new file mode 100644 index 00000000000..495f06026d6 --- /dev/null +++ b/changelog.d/code-health-person-adds-group-projection.changed.md @@ -0,0 +1 @@ +Add a code-health test that fails when a person-level variable's adds or subtracts list names a tax unit, SPM unit or other group variable, which gives every member the whole group amount. It lists today's 10 cases, in 8 variables, as known bugs that may only be removed, and allows three boolean program-enrollment flags. diff --git a/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py b/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py new file mode 100644 index 00000000000..7f6763827f0 --- /dev/null +++ b/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py @@ -0,0 +1,293 @@ +"""Person variables must not add or subtract group variables. + +For a variable with no formula for the period, policyengine-core sums its +`adds` list and takes away its `subtracts` list, calling +`simulation.calculate(listed, period, map_to=)` for +each listed variable (`Simulation._run_formula`). `Simulation.map_result` +maps a group variable to persons with `population.project`, so every member +of the tax unit, SPM unit, family, marital unit or household receives the +whole group amount. A group total of the person variable then counts it once +per member. The `add()` formula helper raises for the same request, so only +these two attributes reach the projection. + +A group variable listed by a different group is split evenly over the members +and then summed, and a person variable listed by a group is summed; both keep +totals, so only person-level variables are checked. + +A list given as a parameter path is checked at every date in the parameter's +history, because a simulation of an earlier year uses the list in force then. + +ALLOWED holds boolean variables where every member should inherit the group's +value. KNOWN_VIOLATIONS holds open bugs. Fixing one makes this file fail until +its entry is deleted, so the list only shrinks. +""" + +from collections import defaultdict +from types import SimpleNamespace + +import pytest +from policyengine_core.commons.formulas import add +from policyengine_core.country_template import ( + CountryTaxBenefitSystem as TemplateCountrySystem, +) +from policyengine_core.country_template.entities import ( + Household as TemplateHousehold, + Person as TemplatePerson, +) +from policyengine_core.parameters import Parameter, ParameterNode, get_parameter +from policyengine_core.periods import YEAR +from policyengine_core.simulations import SimulationBuilder +from policyengine_core.variables import Variable + +from policyengine_us.system import system + + +ATTRIBUTES = ("adds", "subtracts") + +# Boolean person variables that list SPM-unit program receipt. The sum is cast +# to bool, and each consumer reads it as that person's own program enrollment. +ALLOWED = { + "ks_dcf_csfp_categorically_eligible": ( + "every member of an SPM unit receiving SNAP or FDPIR participates in it" + ), + "ma_mbta_enrolled_in_applicable_programs": ( + "every member of an SPM unit receiving SNAP, TAFDC or EAEDC is enrolled" + ), + "tx_dart_reduced_fare_program_eligible": ( + "every member of an SPM unit receiving SNAP or TANF is enrolled" + ), +} + +# (person variable, attribute, group variable): each member gets the whole +# group amount. Each was confirmed as a bug on 2026-10-06. +KNOWN_VIOLATIONS = { + # PolicyEngine/policyengine-us#9888. + ("mt_misc_deductions", "adds", "casualty_loss_deduction"), + # PolicyEngine/policyengine-us#9865. + ("ms_agi_adjustments", "adds", "health_savings_account_ald"), + # PolicyEngine/policyengine-us#9856. + ( + "wv_senior_citizen_disability_deduction_total_modifications", + "adds", + "us_govt_interest", + ), + ( + "oh_unreimbursed_medical_care_expense_deduction_person", + "adds", + "oh_insured_unreimbursed_medical_care_expenses", + ), + ("il_aabd_expense_exemption_person", "adds", "state_withheld_income_tax"), + ( + "ca_riv_general_relief_countable_property_value", + "adds", + "ca_riv_general_relief_countable_vehicle_value", + ), + ( + "ca_riv_general_relief_countable_property_value", + "adds", + "spm_unit_cash_assets", + ), + ( + "ca_riv_general_relief_earned_income_deductions", + "adds", + "additional_medicare_tax", + ), + ( + "ca_riv_general_relief_earned_income_deductions", + "adds", + "state_withheld_income_tax", + ), + ("la_general_relief_gross_income", "adds", "tanf"), +} + + +def listed_names(variable, attribute: str, parameters) -> dict[str, list[str]]: + """Map each name in `variable.` to the dates it is listed. + + A list written on the variable applies at every date ("always"). A + parameter path is read at each value in the parameter's history.""" + listed = getattr(variable, attribute, None) + if not listed: + return {} + if not isinstance(listed, str): + return {name: ["always"] for name in listed} + parameter = get_parameter(parameters, listed) + if not isinstance(parameter, Parameter): + raise TypeError( + f"{variable.name}.{attribute} names {listed}, which is not a " + "parameter holding a list" + ) + dates = defaultdict(list) + for value in parameter.values_list: + for name in value.value or (): + dates[name].append(value.instant_str) + return {name: sorted(set(found)) for name, found in dates.items()} + + +def group_variables_in_person_lists(variables, parameters) -> dict: + """{(person variable, attribute, group variable): (group entity, dates)}. + + Names that are not variables are parameters, which core adds as values.""" + found = {} + for name, variable in variables.items(): + if not variable.entity.is_person: + continue + for attribute in ATTRIBUTES: + for listed, dates in listed_names(variable, attribute, parameters).items(): + source = variables.get(listed) + if source is not None and not source.entity.is_person: + found[(name, attribute, listed)] = (source.entity.key, dates) + return found + + +FOUND = group_variables_in_person_lists(system.variables, system.parameters) + + +def test_person_variables_do_not_add_group_variables(): + new = { + key: value + for key, value in FOUND.items() + if key[0] not in ALLOWED and key not in KNOWN_VIOLATIONS + } + assert not new, ( + "These person variables list group variables, so core gives every " + "member the whole group amount. Make the variable group-level, or list " + "a person-level share. For a boolean that every member should inherit, " + "add it to ALLOWED with the reason.\n" + + "\n".join( + f"{name}.{attribute} lists {listed} ({entity}; {', '.join(dates)})" + for (name, attribute, listed), (entity, dates) in sorted(new.items()) + ) + ) + + +def test_known_violations_only_shrink(): + fixed = sorted(KNOWN_VIOLATIONS - FOUND.keys()) + assert not fixed, ( + "No longer projected; delete these from KNOWN_VIOLATIONS:\n" + + "\n".join(f"{entry}" for entry in fixed) + ) + overlap = sorted(entry for entry in KNOWN_VIOLATIONS if entry[0] in ALLOWED) + assert not overlap, f"Listed as both allowed and a bug: {overlap}" + + +def test_allowed_variables_are_booleans_that_still_list_group_variables(): + still_listing = {name for name, _, _ in FOUND} + stale = sorted(set(ALLOWED) - still_listing) + assert not stale, f"No longer list a group variable; delete from ALLOWED: {stale}" + not_boolean = sorted( + name for name in ALLOWED if system.variables[name].value_type is not bool + ) + assert not not_boolean, ( + "ALLOWED covers booleans only, where projection means every member " + f"inherits the group's value: {not_boolean}" + ) + + +def test_parameter_lists_are_checked_across_their_history(): + parameters = ParameterNode( + "", + data={ + "lists": { + "sources": { + "values": { + "2015-01-01": ["group_amount", "rate"], + "2020-01-01": ["person_amount"], + } + } + } + }, + ) + person = SimpleNamespace(key="person", is_person=True) + group = SimpleNamespace(key="tax_unit", is_person=False) + variables = { + "person_amount": SimpleNamespace(name="person_amount", entity=person), + "group_amount": SimpleNamespace(name="group_amount", entity=group), + "from_parameter": SimpleNamespace( + name="from_parameter", entity=person, adds="lists.sources" + ), + "from_list": SimpleNamespace( + name="from_list", + entity=person, + adds=["person_amount"], + subtracts=["group_amount"], + ), + "group_total": SimpleNamespace( + name="group_total", entity=group, adds=["group_amount"] + ), + } + # The 2015 list still counts after 2020 drops it; "rate" is a parameter. + assert group_variables_in_person_lists(variables, parameters) == { + ("from_parameter", "adds", "group_amount"): ("tax_unit", ["2015-01-01"]), + ("from_list", "subtracts", "group_amount"): ("tax_unit", ["always"]), + } + # A path to a parameter folder fails rather than checking nothing. + with pytest.raises(TypeError, match="not a parameter holding a list"): + listed_names(SimpleNamespace(name="x", adds="lists"), "adds", parameters) + + +def test_core_projects_group_values_listed_in_person_adds(): + """The premise above, on core's template country: each of a household's + three members gets the household's whole 100, the household's sum of the + person variable is 300, and the add() helper refuses the same request.""" + template = TemplateCountrySystem() + + class household_amount(Variable): + value_type = float + entity = TemplateHousehold + definition_period = YEAR + label = "Household amount" + + class person_adds_household_amount(Variable): + value_type = float + entity = TemplatePerson + definition_period = YEAR + label = "Person variable adding a household variable" + adds = ["household_amount"] + + class household_sum_of_person_amounts(Variable): + value_type = float + entity = TemplateHousehold + definition_period = YEAR + label = "Household sum of the person variable" + adds = ["person_adds_household_amount"] + + class person_add_helper(Variable): + value_type = float + entity = TemplatePerson + definition_period = YEAR + label = "Person variable calling add() on a household variable" + + def formula(person, period, parameters): + return add(person, period, ["household_amount"]) + + for variable in ( + household_amount, + person_adds_household_amount, + household_sum_of_person_amounts, + person_add_helper, + ): + template.add_variable(variable) + simulation = SimulationBuilder().build_from_entities( + template, + { + "persons": {"a": {}, "b": {}, "c": {}}, + "households": { + "h": { + "parents": ["a", "b"], + "children": ["c"], + "household_amount": {"2024": 100}, + } + }, + }, + ) + assert simulation.calculate("person_adds_household_amount", 2024).tolist() == [ + 100, + 100, + 100, + ] + assert simulation.calculate("household_sum_of_person_amounts", 2024).tolist() == [ + 300 + ] + with pytest.raises(ValueError, match="not yet implemented"): + simulation.calculate("person_add_helper", 2024) From 2bd81fd1fd38a4ee801ca28b559df5aa9b8fb1a9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 14:00:34 -0400 Subject: [PATCH 2/2] Refresh group projection registry and scope boolean exemptions to reviewed edges --- ...th-person-adds-group-projection.changed.md | 2 +- .../test_no_group_variables_in_person_adds.py | 398 +++++++++++++++--- 2 files changed, 335 insertions(+), 65 deletions(-) diff --git a/changelog.d/code-health-person-adds-group-projection.changed.md b/changelog.d/code-health-person-adds-group-projection.changed.md index 495f06026d6..b1f6c99a1ef 100644 --- a/changelog.d/code-health-person-adds-group-projection.changed.md +++ b/changelog.d/code-health-person-adds-group-projection.changed.md @@ -1 +1 @@ -Add a code-health test that fails when a person-level variable's adds or subtracts list names a tax unit, SPM unit or other group variable, which gives every member the whole group amount. It lists today's 10 cases, in 8 variables, as known bugs that may only be removed, and allows three boolean program-enrollment flags. +Add a code-health test that fails when a person-level variable's adds or subtracts list names a tax unit, SPM unit or other group variable, which gives every member the whole group amount. It records seven known bug pairs in five variables and exempts only the 11 reviewed positive receipt edges in three boolean flags. New group sources and subtraction edges on those flags fail the guard. Regression and property tests cover exact registry membership, stale entries, parameter histories, and Core projection. diff --git a/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py b/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py index 7f6763827f0..cc36269eca5 100644 --- a/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py +++ b/policyengine_us/tests/code_health/test_no_group_variables_in_person_adds.py @@ -7,8 +7,8 @@ maps a group variable to persons with `population.project`, so every member of the tax unit, SPM unit, family, marital unit or household receives the whole group amount. A group total of the person variable then counts it once -per member. The `add()` formula helper raises for the same request, so only -these two attributes reach the projection. +per member. The `add()` formula helper raises for the same request, while +these two attributes automatically reach the projection. A group variable listed by a different group is split evenly over the members and then summed, and a person variable listed by a group is summed; both keep @@ -17,15 +17,17 @@ A list given as a parameter path is checked at every date in the parameter's history, because a simulation of an earlier year uses the list in force then. -ALLOWED holds boolean variables where every member should inherit the group's -value. KNOWN_VIOLATIONS holds open bugs. Fixing one makes this file fail until -its entry is deleted, so the list only shrinks. +ALLOWED holds specific reviewed boolean receipt edges, preserving the baseline +interpretation that each member inherits program receipt. A new source or a +subtraction edge still requires review. KNOWN_VIOLATIONS holds open bugs. +Fixing one makes this file fail until its entry is deleted. """ from collections import defaultdict from types import SimpleNamespace import pytest +from hypothesis import example, given, settings, strategies as st from policyengine_core.commons.formulas import add from policyengine_core.country_template import ( CountryTaxBenefitSystem as TemplateCountrySystem, @@ -43,19 +45,67 @@ ATTRIBUTES = ("adds", "subtracts") +GROUP_ENTITIES = ("tax_unit", "spm_unit", "family", "marital_unit", "household") -# Boolean person variables that list SPM-unit program receipt. The sum is cast -# to bool, and each consumer reads it as that person's own program enrollment. +# Reviewed positive receipt edges in boolean Person variables. Core casts the +# sum to bool. Exact triples preserve the baseline participation interpretation +# without approving future sources or subtraction edges on the same variable. ALLOWED = { - "ks_dcf_csfp_categorically_eligible": ( - "every member of an SPM unit receiving SNAP or FDPIR participates in it" - ), - "ma_mbta_enrolled_in_applicable_programs": ( - "every member of an SPM unit receiving SNAP, TAFDC or EAEDC is enrolled" - ), - "tx_dart_reduced_fare_program_eligible": ( - "every member of an SPM unit receiving SNAP or TANF is enrolled" - ), + ( + "ks_dcf_csfp_categorically_eligible", + "adds", + "snap", + ): "baseline categorical eligibility from positive SPM-unit SNAP benefits", + ( + "ks_dcf_csfp_categorically_eligible", + "adds", + "receives_snap", + ): "baseline categorical eligibility from reported SPM-unit SNAP receipt", + ( + "ks_dcf_csfp_categorically_eligible", + "adds", + "fdpir", + ): "baseline categorical eligibility from positive SPM-unit FDPIR benefits", + ( + "ma_mbta_enrolled_in_applicable_programs", + "adds", + "ma_eaedc", + ): "baseline enrollment from positive SPM-unit EAEDC benefits", + ( + "ma_mbta_enrolled_in_applicable_programs", + "adds", + "ma_tafdc", + ): "baseline enrollment from positive SPM-unit TAFDC benefits", + ( + "ma_mbta_enrolled_in_applicable_programs", + "adds", + "snap", + ): "baseline enrollment from positive SPM-unit SNAP benefits", + ( + "ma_mbta_enrolled_in_applicable_programs", + "adds", + "receives_snap", + ): "baseline enrollment from reported SPM-unit SNAP receipt", + ( + "tx_dart_reduced_fare_program_eligible", + "adds", + "snap", + ): "baseline enrollment from positive SPM-unit SNAP benefits", + ( + "tx_dart_reduced_fare_program_eligible", + "adds", + "receives_snap", + ): "baseline enrollment from reported SPM-unit SNAP receipt", + ( + "tx_dart_reduced_fare_program_eligible", + "adds", + "tanf", + ): "baseline enrollment from positive SPM-unit TANF benefits", + ( + "tx_dart_reduced_fare_program_eligible", + "adds", + "receives_tanf", + ): "baseline enrollment from reported SPM-unit TANF receipt", } # (person variable, attribute, group variable): each member gets the whole @@ -63,19 +113,6 @@ KNOWN_VIOLATIONS = { # PolicyEngine/policyengine-us#9888. ("mt_misc_deductions", "adds", "casualty_loss_deduction"), - # PolicyEngine/policyengine-us#9865. - ("ms_agi_adjustments", "adds", "health_savings_account_ald"), - # PolicyEngine/policyengine-us#9856. - ( - "wv_senior_citizen_disability_deduction_total_modifications", - "adds", - "us_govt_interest", - ), - ( - "oh_unreimbursed_medical_care_expense_deduction_person", - "adds", - "oh_insured_unreimbursed_medical_care_expenses", - ), ("il_aabd_expense_exemption_person", "adds", "state_withheld_income_tax"), ( "ca_riv_general_relief_countable_property_value", @@ -143,17 +180,54 @@ def group_variables_in_person_lists(variables, parameters) -> dict: FOUND = group_variables_in_person_lists(system.variables, system.parameters) -def test_person_variables_do_not_add_group_variables(): - new = { +def unexpected_group_variables(found, allowed, known) -> dict: + """Only exact reviewed or known triples exempt a projected group edge.""" + return { key: value - for key, value in FOUND.items() - if key[0] not in ALLOWED and key not in KNOWN_VIOLATIONS + for key, value in found.items() + if key not in allowed and key not in known } + + +def assert_known_violations_live(found, allowed, known): + """Recorded bugs must remain live and disjoint from reviewed receipt edges.""" + fixed = sorted(set(known) - found.keys()) + assert not fixed, ( + "No longer projected; delete these from KNOWN_VIOLATIONS:\n" + + "\n".join(f"{entry}" for entry in fixed) + ) + overlap = sorted(set(known) & set(allowed)) + assert not overlap, f"Listed as both allowed and a bug: {overlap}" + + +def assert_allowed_variables_live(found, variables, allowed): + """Every reviewed edge must remain live, with a boolean Person adds target.""" + stale = sorted(set(allowed) - found.keys()) + assert not stale, "No longer projected; delete these from ALLOWED:\n" + "\n".join( + f"{entry}" for entry in stale + ) + not_boolean = sorted( + {name for name, _, _ in allowed if variables[name].value_type is not bool} + ) + assert not not_boolean, ( + "ALLOWED covers booleans only, where projection means every member " + f"inherits the group's value: {not_boolean}" + ) + not_person = sorted( + {name for name, _, _ in allowed if not variables[name].entity.is_person} + ) + assert not not_person, f"ALLOWED covers Person variables only: {not_person}" + not_adds = sorted(key for key in allowed if key[1] != "adds") + assert not not_adds, f"ALLOWED covers positive adds edges only: {not_adds}" + + +def test_person_variables_do_not_add_group_variables(): + new = unexpected_group_variables(FOUND, ALLOWED, KNOWN_VIOLATIONS) assert not new, ( "These person variables list group variables, so core gives every " "member the whole group amount. Make the variable group-level, or list " - "a person-level share. For a boolean that every member should inherit, " - "add it to ALLOWED with the reason.\n" + "a person-level share. For a reviewed boolean receipt edge that every " + "member should inherit, add its exact triple to ALLOWED with the reason.\n" + "\n".join( f"{name}.{attribute} lists {listed} ({entity}; {', '.join(dates)})" for (name, attribute, listed), (entity, dates) in sorted(new.items()) @@ -162,26 +236,217 @@ def test_person_variables_do_not_add_group_variables(): def test_known_violations_only_shrink(): - fixed = sorted(KNOWN_VIOLATIONS - FOUND.keys()) - assert not fixed, ( - "No longer projected; delete these from KNOWN_VIOLATIONS:\n" - + "\n".join(f"{entry}" for entry in fixed) - ) - overlap = sorted(entry for entry in KNOWN_VIOLATIONS if entry[0] in ALLOWED) - assert not overlap, f"Listed as both allowed and a bug: {overlap}" + assert_known_violations_live(FOUND, ALLOWED, KNOWN_VIOLATIONS) def test_allowed_variables_are_booleans_that_still_list_group_variables(): - still_listing = {name for name, _, _ in FOUND} - stale = sorted(set(ALLOWED) - still_listing) - assert not stale, f"No longer list a group variable; delete from ALLOWED: {stale}" - not_boolean = sorted( - name for name in ALLOWED if system.variables[name].value_type is not bool + assert_allowed_variables_live(FOUND, system.variables, ALLOWED) + + +def test_boolean_exception_does_not_allow_snap_subtraction(monkeypatch): + target = system.variables["ks_dcf_csfp_categorically_eligible"] + monkeypatch.setattr(target, "subtracts", ["snap"]) + monkeypatch.setitem( + globals(), + "FOUND", + group_variables_in_person_lists(system.variables, system.parameters), ) - assert not not_boolean, ( - "ALLOWED covers booleans only, where projection means every member " - f"inherits the group's value: {not_boolean}" + # Core attaches each simulation to its system; restore the previous reference. + monkeypatch.setattr( + system, "simulation", getattr(system, "simulation", None), raising=False ) + simulation = SimulationBuilder().build_from_entities( + system, + { + "people": { + "recipient": { + "ssi": {"2025-05": 0}, + "receives_ssi": {"2025-05": False}, + "msp": {"2025-05": 0}, + } + }, + "households": { + "household": { + "members": ["recipient"], + "state_code": {"2025": "KS"}, + } + }, + "spm_units": { + "spm_unit": { + "members": ["recipient"], + "snap": {"2025-05": 100}, + "receives_snap": {"2025-05": False}, + "fdpir": {"2025": 0}, + } + }, + }, + ) + # With reported receipt false and only computed SNAP positive, the sum + # becomes 0 + False + 100 + False + 0 / 12 + 0 - 100 = 0 (false). + assert simulation.calculate(target.name, "2025-05").tolist() == [False] + # The existing positive SNAP edge must not exempt this subtraction. + with pytest.raises( + AssertionError, + match=r"ks_dcf_csfp_categorically_eligible\.subtracts lists snap", + ): + test_person_variables_do_not_add_group_variables() + + +@settings(max_examples=50, deadline=None) +@given( + group_entity=st.sampled_from(GROUP_ENTITIES), + edges=st.sets( + st.tuples( + st.sampled_from(("flag", "other_flag")), + st.sampled_from(ATTRIBUTES), + st.sampled_from(("snap", "fdpir", "tanf")), + ), + max_size=12, + ), + reviewed_sources=st.sets(st.sampled_from(("snap", "fdpir", "tanf"))), +) +def test_exact_registries_accept_safe_partitions_and_reject_new_edges( + group_entity, edges, reviewed_sources +): + reviewed_edge = ("flag", "adds", "snap") + subtract_snap = ("flag", "subtracts", "snap") + edges = (edges | {reviewed_edge}) - {subtract_snap} + found = {key: (group_entity, ["always"]) for key in edges} + allowed = { + key: "reviewed positive receipt" + for key in edges + if key[1] == "adds" and (key[2] in reviewed_sources or key == reviewed_edge) + } + known = edges - allowed.keys() + variables = { + name: SimpleNamespace( + value_type=bool, entity=SimpleNamespace(key="person", is_person=True) + ) + for name, _, _ in edges + } + assert unexpected_group_variables(found, allowed, known) == {} + assert_known_violations_live(found, allowed, known) + assert_allowed_variables_live(found, variables, allowed) + + # An allowed name does not approve another source or the opposite attribute. + for new_edge in ( + ("flag", "adds", "unreviewed_source"), + ("flag", "subtracts", "unreviewed_source"), + subtract_snap, + ): + changed = {**found, new_edge: (group_entity, ["always"])} + assert unexpected_group_variables(changed, allowed, known) == { + new_edge: (group_entity, ["always"]) + } + + +@settings(max_examples=50, deadline=None) +@given( + group_entity=st.sampled_from(GROUP_ENTITIES), + source_index=st.integers(min_value=0, max_value=100), +) +def test_exact_registries_require_live_disjoint_entries(group_entity, source_index): + first = ("flag", "adds", f"source_{source_index}") + sibling = ("flag", "adds", f"sibling_{source_index}") + bug = ("known_bug", "subtracts", f"source_{source_index}") + found = {key: (group_entity, ["always"]) for key in (first, sibling, bug)} + allowed = {first: "reviewed receipt", sibling: "another reviewed receipt"} + known = {bug} + variables = { + "flag": SimpleNamespace( + value_type=bool, entity=SimpleNamespace(key="person", is_person=True) + ) + } + assert_known_violations_live(found, allowed, known) + assert_allowed_variables_live(found, variables, allowed) + + # The variable still lists a group source: only the precise edge went stale. + without_first = {key: value for key, value in found.items() if key != first} + with pytest.raises(AssertionError, match="delete these from ALLOWED"): + assert_allowed_variables_live(without_first, variables, allowed) + without_bug = {key: value for key, value in found.items() if key != bug} + with pytest.raises(AssertionError, match="delete these from KNOWN_VIOLATIONS"): + assert_known_violations_live(without_bug, allowed, known) + with pytest.raises(AssertionError, match="both allowed and a bug"): + assert_known_violations_live(found, allowed, known | {first}) + + +@settings(max_examples=50, deadline=None) +@given( + group_entity=st.sampled_from(GROUP_ENTITIES), + invalid_schema=st.sampled_from(("integer", "float", "group", "subtracts")), +) +def test_allowed_edges_require_boolean_person_adds(group_entity, invalid_schema): + attribute = "subtracts" if invalid_schema == "subtracts" else "adds" + key = ("flag", attribute, "group_amount") + found = {key: (group_entity, ["always"])} + target = SimpleNamespace( + value_type={"integer": int, "float": float}.get(invalid_schema, bool), + entity=SimpleNamespace( + key=group_entity if invalid_schema == "group" else "person", + is_person=invalid_schema != "group", + ), + ) + message = { + "integer": "booleans only", + "float": "booleans only", + "group": "Person variables only", + "subtracts": "positive adds edges only", + }[invalid_schema] + with pytest.raises(AssertionError, match=message): + assert_allowed_variables_live(found, {"flag": target}, {key: "receipt"}) + + +@settings(max_examples=50, deadline=None) +@example( + group_entity="tax_unit", + attribute="adds", + history=[["group_a", "rate"], ["person_amount"]], +) +@given( + group_entity=st.sampled_from(GROUP_ENTITIES), + attribute=st.sampled_from(ATTRIBUTES), + history=st.lists( + st.lists( + st.sampled_from(("group_a", "group_b", "person_amount", "rate")), + max_size=6, + ), + min_size=1, + max_size=5, + ), +) +def test_parameter_history_preserves_every_group_source( + group_entity, attribute, history +): + dates = [f"{2015 + index}-01-01" for index in range(len(history))] + parameters = ParameterNode( + "", + data={"lists": {"sources": {"values": dict(zip(dates, history))}}}, + ) + person = SimpleNamespace(key="person", is_person=True) + group = SimpleNamespace(key=group_entity, is_person=False) + target = SimpleNamespace( + name="from_parameter", entity=person, **{attribute: "lists.sources"} + ) + variables = { + "from_parameter": target, + "person_amount": SimpleNamespace(name="person_amount", entity=person), + "group_a": SimpleNamespace(name="group_a", entity=group), + "group_b": SimpleNamespace(name="group_b", entity=group), + "group_total": SimpleNamespace( + name="group_total", entity=group, **{attribute: "lists.sources"} + ), + } + expected_dates = { + name: sorted({date for date, names in zip(dates, history) if name in names}) + for name in {name for names in history for name in names} + } + assert listed_names(target, attribute, parameters) == expected_dates + assert group_variables_in_person_lists(variables, parameters) == { + ("from_parameter", attribute, name): (group_entity, listed_dates) + for name, listed_dates in expected_dates.items() + if name in ("group_a", "group_b") + } def test_parameter_lists_are_checked_across_their_history(): @@ -226,10 +491,14 @@ def test_parameter_lists_are_checked_across_their_history(): listed_names(SimpleNamespace(name="x", adds="lists"), "adds", parameters) -def test_core_projects_group_values_listed_in_person_adds(): - """The premise above, on core's template country: each of a household's - three members gets the household's whole 100, the household's sum of the - person variable is 300, and the add() helper refuses the same request.""" +@settings(max_examples=50, deadline=None) +@example(member_count=3, amount=100) +@given( + member_count=st.integers(min_value=1, max_value=6), + amount=st.integers(min_value=-1000, max_value=1000), +) +def test_core_projects_group_values_listed_in_person_adds(member_count, amount): + """Projection repeats a whole group amount; aggregation multiplies it by size.""" template = TemplateCountrySystem() class household_amount(Variable): @@ -268,26 +537,27 @@ def formula(person, period, parameters): person_add_helper, ): template.add_variable(variable) + members = [f"person_{index}" for index in range(member_count)] simulation = SimulationBuilder().build_from_entities( template, { - "persons": {"a": {}, "b": {}, "c": {}}, + "persons": {member: {} for member in members}, "households": { "h": { - "parents": ["a", "b"], - "children": ["c"], - "household_amount": {"2024": 100}, + "parents": members[:2], + "children": members[2:], + "household_amount": {"2024": amount}, } }, }, ) - assert simulation.calculate("person_adds_household_amount", 2024).tolist() == [ - 100, - 100, - 100, - ] + assert ( + simulation.calculate("person_adds_household_amount", 2024).tolist() + == [amount] * member_count + ) + # The explicit example repeats 100 three times: 3 * 100 = 300. assert simulation.calculate("household_sum_of_person_amounts", 2024).tolist() == [ - 300 + member_count * amount ] with pytest.raises(ValueError, match="not yet implemented"): simulation.calculate("person_add_helper", 2024)