diff --git a/changelog.d/co-ccap-activity-test.fixed.md b/changelog.d/co-ccap-activity-test.fixed.md new file mode 100644 index 00000000000..0ad57581171 --- /dev/null +++ b/changelog.d/co-ccap-activity-test.fixed.md @@ -0,0 +1 @@ +Require an eligible activity for Colorado's Child Care Assistance Program under 8 CCR 1403-1 section 3.111. diff --git a/policyengine_us/parameters/gov/states/co/ccap/activity/earned_income_sources.yaml b/policyengine_us/parameters/gov/states/co/ccap/activity/earned_income_sources.yaml new file mode 100644 index 00000000000..49e34da6180 --- /dev/null +++ b/policyengine_us/parameters/gov/states/co/ccap/activity/earned_income_sources.yaml @@ -0,0 +1,15 @@ +description: Colorado counts these income sources as earnings from employment or self-employment under the Child Care Assistance Program activity requirement. +values: + 2020-01-01: + - employment_income + - self_employment_income + - sstb_self_employment_income + - farm_operations_income + +metadata: + unit: list + period: year + label: Colorado CCAP activity requirement earned income sources + reference: + - title: 8 CCR 1403-1 Section 3.111(G)(1) and (2), employment and self-employed criteria + href: https://www.sos.state.co.us/CCR/GenerateRulePdf.do?ruleVersionId=12658&fileName=8%20CCR%201403-1#page=36 diff --git a/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_activity_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_activity_eligible.yaml new file mode 100644 index 00000000000..3f7d14b2e8e --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_activity_eligible.yaml @@ -0,0 +1,759 @@ +# 8 CCR 1403-1 section 3.111(B)(1), (D), (E) and (G); 3.115(C); 3.116(A); 3.119(D); 3.121(B)(3). +# Federal minimum wage $7.25: 40 hours for 52 weeks is 2,080 hours, so the floor is $15,080. +- name: Case 1, single parent with wages and no recorded hours meets the requirement. + period: 2026-01 + input: + people: + parent: + age: 30 + employment_income: 20_000 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 2, single parent with no activity does not meet the requirement. + period: 2026-01 + input: + people: + parent: + age: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 3, wages exactly at the minimum wage floor for the hours worked meet the requirement. + period: 2026-01 + input: + people: + parent: + age: 30 + employment_income: 15_080 + weekly_hours_worked_before_lsr: 40 + weeks_worked: 52 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 4, wages a dollar below the floor fail 3.111(G)(1)(b). + period: 2026-01 + input: + people: + parent: + age: 30 + employment_income: 15_079 + weekly_hours_worked_before_lsr: 40 + weeks_worked: 52 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 5, the floor counts only the weeks worked, so $10,000 for 26 weeks at 40 hours passes. + period: 2026-01 + input: + people: + parent: + age: 30 + employment_income: 10_000 + weekly_hours_worked_before_lsr: 40 + weeks_worked: 26 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + # 40 x 26 = 1,040 hours x $7.25 = $7,540 <= $10,000 + co_ccap_activity_eligible: true + +- name: Case 6, with no weeks reported, usual hours run the full year, so the same $10,000 fails. + period: 2026-01 + input: + people: + parent: + age: 30 + employment_income: 10_000 + weekly_hours_worked_before_lsr: 40 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 7, self-employment income above the floor meets 3.111(G)(2). + period: 2026-01 + input: + people: + parent: + age: 30 + self_employment_income: 20_000 + weekly_hours_worked_before_lsr: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + # 30 x 52 = 1,560 hours x $7.25 = $11,310 <= $20,000 + co_ccap_activity_eligible: true + +- name: Case 8, a business loss fails 3.111(G)(2)(c). + period: 2026-01 + input: + people: + parent: + age: 30 + self_employment_income: -5_000 + weekly_hours_worked_before_lsr: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 9, a business loss does not erase wages. + period: 2026-01 + input: + people: + parent: + age: 30 + employment_income: 20_000 + self_employment_income: -30_000 + weekly_hours_worked_before_lsr: 40 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 10, a full-time college student meets 3.111(G)(4)(a)(1), which the model takes to be a first bachelor's degree or less. + period: 2026-01 + input: + people: + parent: + age: 30 + is_full_time_college_student: true + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 11, two parents who both work meet 3.111(D)(1)(c)(1). + period: 2026-01 + input: + people: + parent1: + age: 30 + employment_income: 20_000 + parent2: + age: 30 + employment_income: 20_000 + child: + age: 3 + spm_units: + spm_unit: + members: [parent1, parent2, child] + tax_units: + tax_unit: + members: [parent1, parent2, child] + households: + household: + members: [parent1, parent2, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 12, one parent working and the other at home and able to provide care fail 3.111(D)(1)(c). + period: 2026-01 + input: + people: + parent1: + age: 30 + employment_income: 20_000 + parent2: + age: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [parent1, parent2, child] + tax_units: + tax_unit: + members: [parent1, parent2, child] + households: + household: + members: [parent1, parent2, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 13, one parent working and the other incapacitated meet 3.111(D)(1)(c)(3). + period: 2026-01 + input: + people: + parent1: + age: 30 + employment_income: 20_000 + parent2: + age: 30 + is_disabled: true + child: + age: 3 + spm_units: + spm_unit: + members: [parent1, parent2, child] + tax_units: + tax_unit: + members: [parent1, parent2, child] + households: + household: + members: [parent1, parent2, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 14, an incapacitated single parent with no activity fails 3.111(E). + period: 2026-01 + input: + people: + parent: + age: 30 + is_disabled: true + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 15, two incapacitated parents with no activity fail, since neither is in an activity. + period: 2026-01 + input: + people: + parent1: + age: 30 + is_disabled: true + parent2: + age: 30 + is_disabled: true + child: + age: 3 + spm_units: + spm_unit: + members: [parent1, parent2, child] + tax_units: + tax_unit: + members: [parent1, parent2, child] + households: + household: + members: [parent1, parent2, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 16, an incarcerated parent counts as absent under 3.111(D)(1)(b), so the working parent qualifies. + period: 2026-01 + input: + people: + parent1: + age: 30 + employment_income: 20_000 + parent2: + age: 30 + is_incarcerated: true + child: + age: 3 + spm_units: + spm_unit: + members: [parent1, parent2, child] + tax_units: + tax_unit: + members: [parent1, parent2, child] + households: + household: + members: [parent1, parent2, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 17, a retired grandparent who files separately is not a caretaker under 3.103(C). + period: 2026-01 + input: + people: + grandparent: + age: 70 + parent: + age: 30 + employment_income: 20_000 + child: + age: 3 + spm_units: + spm_unit: + members: [grandparent, parent, child] + tax_units: + grandparent_tax_unit: + members: [grandparent] + parent_tax_unit: + members: [parent, child] + households: + household: + members: [grandparent, parent, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 18, a working grandparent who files separately does not supply the claiming parent's activity. + period: 2026-01 + input: + people: + grandparent: + age: 60 + employment_income: 50_000 + parent: + age: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [grandparent, parent, child] + tax_units: + grandparent_tax_unit: + members: [grandparent] + parent_tax_unit: + members: [parent, child] + households: + household: + members: [grandparent, parent, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 19, the activity input covers job search and other activities the model does not track. + period: 2026-01 + input: + people: + parent: + age: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + meets_ccdf_activity_test: true + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 20, TANF receipt alone does not meet the requirement, since 3.115(C) holds caretakers of children on basic cash assistance to the low-income test. + period: 2026-01 + input: + people: + parent: + age: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + is_tanf_enrolled: true + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 21, an eligible child under protective services exempts the household under 3.119(D). + period: 2026-01 + input: + people: + caretaker: + age: 50 + child: + age: 3 + receives_or_needs_protective_services: true + spm_units: + spm_unit: + members: [caretaker, child] + tax_units: + tax_unit: + members: [caretaker, child] + households: + household: + members: [caretaker, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 22, an eligible child in foster care exempts the household under 3.118(A) and 3.119(D). + period: 2026-01 + input: + people: + caretaker: + age: 50 + child: + age: 3 + is_in_foster_care: true + spm_units: + spm_unit: + members: [caretaker, child] + tax_units: + tax_unit: + members: [caretaker, child] + households: + household: + members: [caretaker, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 23, a family experiencing homelessness is exempt under 3.121(B)(3)(a). + period: 2026-01 + input: + people: + parent: + age: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + is_homeless: true + output: + co_ccap_activity_eligible: true + +- name: Case 24, a working parent with an eligible child receives the subsidy through the full pipeline. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + child: + age: 3 + childcare_hours_per_day: 8 + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 12_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + county: DENVER_COUNTY_CO + output: + co_ccap_activity_eligible: true + co_ccap_eligible: true + # $1,000 a month less the $128 parent fee in court_supervision.yaml Case 5. + co_ccap_subsidy: 872 + +- name: Case 25, the same family with the same income from a pension receives nothing. + period: 2026-01 + input: + people: + parent: + age: 30 + pension_income: 30_000 + child: + age: 3 + childcare_hours_per_day: 8 + spm_units: + spm_unit: + members: [parent, child] + spm_unit_pre_subsidy_childcare_expenses: 12_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + county: DENVER_COUNTY_CO + output: + co_ccap_activity_eligible: false + co_ccap_eligible: false + co_ccap_subsidy: 0 + +- name: Case 26, a graduate student is not in a first bachelor's degree program, so the student fails 3.111(G)(4)(a)(1). + period: 2026-01 + input: + people: + parent: + age: 30 + is_full_time_college_student: true + co_ccap_in_eligible_education: false + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 27, a part-time college student meets 3.111(G)(4)(a)(1), which sets no full-time requirement. + period: 2026-01 + input: + people: + parent: + age: 30 + is_part_time_college_student: true + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 28, a Colorado Works participant in allowable work activities meets 3.116(A) through the activity input. + period: 2026-01 + input: + people: + parent: + age: 30 + child: + age: 3 + spm_units: + spm_unit: + members: [parent, child] + is_tanf_enrolled: true + meets_ccdf_activity_test: true + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 29, a father whom the child's parent ids name counts as a caretaker under 3.111(D)(2) though he files separately, so his lack of activity blocks the working mother. + period: 2026-01 + input: + people: + mother: + person_id: 1 + age: 30 + employment_income: 30_000 + father: + person_id: 2 + age: 30 + child: + person_id: 3 + age: 3 + parent_1_id: 1 + parent_2_id: 2 + spm_units: + spm_unit: + members: [mother, father, child] + tax_units: + mother_tax_unit: + members: [mother, child] + father_tax_unit: + members: [father] + households: + household: + members: [mother, father, child] + state_code: CO + output: + co_ccap_activity_eligible: false + +- name: Case 30, the same father working supplies the activity beside an incapacitated mother under 3.111(D)(1)(c)(3). + period: 2026-01 + input: + people: + mother: + person_id: 1 + age: 30 + is_disabled: true + father: + person_id: 2 + age: 30 + employment_income: 30_000 + child: + person_id: 3 + age: 3 + parent_1_id: 1 + parent_2_id: 2 + spm_units: + spm_unit: + members: [mother, father, child] + tax_units: + mother_tax_unit: + members: [mother, child] + father_tax_unit: + members: [father] + households: + household: + members: [mother, father, child] + state_code: CO + output: + co_ccap_activity_eligible: true + +- name: Case 31, a working grandparent whom the mother's parent ids name is still not the child's caretaker, so the inactive mother fails. + period: 2026-01 + input: + people: + grandparent: + person_id: 1 + age: 60 + employment_income: 50_000 + mother: + person_id: 2 + age: 30 + parent_1_id: 1 + child: + person_id: 3 + age: 3 + parent_1_id: 2 + spm_units: + spm_unit: + members: [grandparent, mother, child] + tax_units: + grandparent_tax_unit: + members: [grandparent] + mother_tax_unit: + members: [mother, child] + households: + household: + members: [grandparent, mother, child] + state_code: CO + output: + co_ccap_activity_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_eligible.yaml index 9d62bb51f3f..cde27d747c1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_eligible.yaml @@ -7,6 +7,8 @@ co_ccap_is_in_re_determination_process: false co_ccap_re_determination_eligible: 2023-01: true + co_ccap_activity_eligible: + 2023-01: true state_code: CO output: co_ccap_eligible: @@ -21,6 +23,8 @@ co_ccap_is_in_re_determination_process: false co_ccap_re_determination_eligible: 2023-01: true + co_ccap_activity_eligible: + 2023-01: true state_code: CO output: co_ccap_eligible: @@ -35,6 +39,8 @@ co_ccap_is_in_re_determination_process: false co_ccap_re_determination_eligible: 2023-01: true + co_ccap_activity_eligible: + 2023-01: true state_code: CO output: co_ccap_eligible: @@ -49,6 +55,8 @@ co_ccap_is_in_re_determination_process: true co_ccap_re_determination_eligible: 2023-01: true + co_ccap_activity_eligible: + 2023-01: true state_code: CO output: co_ccap_eligible: @@ -63,6 +71,8 @@ co_ccap_is_in_re_determination_process: true co_ccap_re_determination_eligible: 2023-01: false + co_ccap_activity_eligible: + 2023-01: true state_code: CO output: co_ccap_eligible: @@ -77,6 +87,8 @@ co_ccap_is_in_re_determination_process: true co_ccap_re_determination_eligible: 2023-01: false + co_ccap_activity_eligible: + 2023-01: true state_code: CO output: co_ccap_eligible: @@ -91,6 +103,8 @@ co_ccap_is_in_re_determination_process: false co_ccap_re_determination_eligible: 2023-01: false + co_ccap_activity_eligible: + 2023-01: true state_code: CO output: co_ccap_eligible: @@ -105,7 +119,41 @@ co_ccap_is_in_re_determination_process: true co_ccap_re_determination_eligible: 2023-01: false + co_ccap_activity_eligible: + 2023-01: true state_code: CO output: co_ccap_eligible: 2023-01: false + +- name: Eligible at entry with no activity, then ineligible for cccap + period: 2023 + input: + co_ccap_is_in_entry_process: true + co_ccap_entry_eligible: + 2023-01: true + co_ccap_is_in_re_determination_process: false + co_ccap_re_determination_eligible: + 2023-01: false + co_ccap_activity_eligible: + 2023-01: false + state_code: CO + output: + co_ccap_eligible: + 2023-01: false + +- name: Eligible at re-determination with no activity, then ineligible for cccap + period: 2023 + input: + co_ccap_is_in_entry_process: false + co_ccap_entry_eligible: + 2023-01: false + co_ccap_is_in_re_determination_process: true + co_ccap_re_determination_eligible: + 2023-01: true + co_ccap_activity_eligible: + 2023-01: false + state_code: CO + output: + co_ccap_eligible: + 2023-01: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_in_eligible_education.yaml b/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_in_eligible_education.yaml new file mode 100644 index 00000000000..2bfd3f8e5dd --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/co/ccap/co_ccap_in_eligible_education.yaml @@ -0,0 +1,44 @@ +# 8 CCR 1403-1 section 3.111(G)(4): training, post-secondary education for a first +# bachelor's degree or less, and basic education. +- name: Case 1, a full-time college student counts. + period: 2026 + input: + is_full_time_college_student: true + age: 30 + state_code: CO + output: + co_ccap_in_eligible_education: true + +- name: Case 2, a part-time college student counts, since 3.111(G)(4)(a)(1) sets no full-time requirement. + period: 2026 + input: + is_part_time_college_student: true + age: 30 + state_code: CO + output: + co_ccap_in_eligible_education: true + +- name: Case 3, a teen parent in high school counts as in basic education under 3.111(G)(4)(b). + period: 2026 + input: + age: 17 + state_code: CO + output: + co_ccap_in_eligible_education: true + +- name: Case 4, an adult who is not enrolled does not count. + period: 2026 + input: + age: 30 + state_code: CO + output: + co_ccap_in_eligible_education: false + +- name: Case 5, the variable applies only in Colorado. + period: 2026 + input: + is_full_time_college_student: true + age: 30 + state_code: NM + output: + co_ccap_in_eligible_education: false diff --git a/policyengine_us/tests/test_co_ccap_activity_properties.py b/policyengine_us/tests/test_co_ccap_activity_properties.py new file mode 100644 index 00000000000..3bbbcbc8b39 --- /dev/null +++ b/policyengine_us/tests/test_co_ccap_activity_properties.py @@ -0,0 +1,289 @@ +"""Invariants of Colorado's child care activity requirement. + +8 CCR 1403-1 section 3.111(B)(1) requires caretakers to take part in an eligible +activity, and (D) and (E) say which households qualify. + +1. A caretaker works when earnings are positive and at least the federal minimum + wage for the hours worked (3.111(G)(1)(b) and (G)(2)(d)), and studies when + enrolled in college full or part time (3.111(G)(4)). Without the exempt paths, + a household qualifies when a caretaker is in an activity and every other + caretaker is in an activity or incapacitated (3.111(D)(1)(c), 3.111(E)). +2. No family without the requirement receives a subsidy: co_ccap_eligible implies + co_ccap_activity_eligible, and co_ccap_subsidy is zero without it. +3. Raising a caretaker's earnings never removes eligibility while hours stay the + same. +4. An adult who files separately, claims no eligible child and isn't named by the + child's parent ids doesn't change the answer, whatever that adult earns. +5. When the child's parent ids name both parents, tax filing doesn't change the + answer (3.111(D)(2)). +6. The exempt paths each qualify a family with no activity (3.119(D), + 3.121(B)(3)(a), and the activity input), and TANF receipt alone doesn't + (3.111(D)(6), 3.115(C)). + +The YAML cases pin single families. These generate earnings, hours, weeks and +family makeups, which YAML can't express. +""" + +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEAR = 2026 +MONTH = "2026-01" +MINIMUM_WAGE = 7.25 +WEEKS_IN_YEAR = 52 + +caretakers = st.fixed_dictionaries( + { + "wages": st.integers(min_value=0, max_value=60_000), + "business": st.integers(min_value=-30_000, max_value=60_000), + "hours": st.integers(min_value=0, max_value=70), + "weeks": st.integers(min_value=0, max_value=52), + "student": st.booleans(), + "part_time": st.booleans(), + "disabled": st.booleans(), + } +) + + +def person_input(caretaker): + return { + "age": {YEAR: 30}, + "employment_income": {YEAR: caretaker["wages"]}, + "self_employment_income": {YEAR: caretaker["business"]}, + "weekly_hours_worked_before_lsr": {YEAR: caretaker["hours"]}, + "weeks_worked": {YEAR: caretaker["weeks"]}, + "is_full_time_college_student": {YEAR: caretaker["student"]}, + "is_part_time_college_student": {YEAR: caretaker.get("part_time", False)}, + "is_disabled": {YEAR: caretaker["disabled"]}, + } + + +def simulate( + caretakers, + spm_unit=None, + household=None, + other_adult=None, + child=None, + link_parents=False, + second_files_apart=False, +): + names = [f"caretaker{index}" for index in range(len(caretakers))] + people = {name: person_input(item) for name, item in zip(names, caretakers)} + people["child"] = {"age": {YEAR: 3}, **(child or {})} + family = names + ["child"] + if link_parents: + # Ids start at 1, since 0 means an unknown parent. + for index, name in enumerate(family): + people[name]["person_id"] = {YEAR: index + 1} + for slot, index in zip(("parent_1_id", "parent_2_id"), range(len(names))): + people["child"][slot] = {YEAR: index + 1} + if second_files_apart and len(names) > 1: + tax_units = { + "claiming": {"members": [names[0], "child"]}, + "apart": {"members": names[1:]}, + } + else: + tax_units = {"claiming": {"members": family}} + members = list(family) + if other_adult is not None: + people["other_adult"] = {"age": {YEAR: 60}, **other_adult} + tax_units["other"] = {"members": ["other_adult"]} + members.append("other_adult") + situation = { + "people": people, + "tax_units": tax_units, + "spm_units": { + "spm_unit": { + "members": members, + "spm_unit_pre_subsidy_childcare_expenses": {YEAR: 12_000}, + **(spm_unit or {}), + } + }, + "families": {"family": {"members": members}}, + "marital_units": {name: {"members": [name]} for name in people}, + "households": { + "household": { + "members": members, + "state_name": {YEAR: "CO"}, + **(household or {}), + } + }, + } + return Simulation(situation=situation) + + +def activity_eligible(simulation): + return bool(simulation.calculate("co_ccap_activity_eligible", MONTH)[0]) + + +def in_activity(caretaker): + # Like the model, this divides all earnings by all hours: the inputs hold one + # figure for weekly hours, so a job and a business can't be timed apart. + earnings = max(caretaker["wages"], 0) + max(caretaker["business"], 0) + weeks = caretaker["weeks"] or WEEKS_IN_YEAR + floor = MINIMUM_WAGE * caretaker["hours"] * weeks + studies = caretaker["student"] or caretaker.get("part_time", False) + return studies or (earnings > 0 and earnings >= floor) + + +def rule_says_eligible(caretakers): + active = [in_activity(item) for item in caretakers] + covered = [ + is_active or item["disabled"] for is_active, item in zip(active, caretakers) + ] + return any(active) and all(covered) + + +@settings(max_examples=25, deadline=None) +@given(family=st.lists(caretakers, min_size=1, max_size=2)) +# A dollar below and exactly at the floor for 2,080 hours. +@example( + family=[ + { + "wages": 15_079, + "business": 0, + "hours": 40, + "weeks": 52, + "student": False, + "disabled": False, + } + ] +) +@example( + family=[ + { + "wages": 15_080, + "business": 0, + "hours": 40, + "weeks": 52, + "student": False, + "disabled": False, + } + ] +) +# An incapacitated caretaker alone, and beside a working caretaker. +@example( + family=[ + { + "wages": 0, + "business": 0, + "hours": 0, + "weeks": 0, + "student": False, + "disabled": True, + } + ] +) +@example( + family=[ + { + "wages": 20_000, + "business": 0, + "hours": 0, + "weeks": 0, + "student": False, + "disabled": False, + }, + { + "wages": 0, + "business": 0, + "hours": 0, + "weeks": 0, + "student": False, + "disabled": True, + }, + ] +) +def test_activity_requirement_follows_the_rule(family): + simulation = simulate(family) + assert activity_eligible(simulation) == rule_says_eligible(family) + + +@settings(max_examples=15, deadline=None) +@given(family=st.lists(caretakers, min_size=1, max_size=2)) +def test_no_family_without_the_requirement_receives_a_subsidy(family): + simulation = simulate(family) + eligible = bool(simulation.calculate("co_ccap_eligible", MONTH)[0]) + subsidy = float(simulation.calculate("co_ccap_subsidy", MONTH)[0]) + if not activity_eligible(simulation): + assert not eligible + assert subsidy == 0 + if eligible: + assert activity_eligible(simulation) + assert subsidy >= 0 + + +@settings(max_examples=15, deadline=None) +@given( + family=st.lists(caretakers, min_size=1, max_size=2), + raise_by=st.integers(min_value=1, max_value=40_000), +) +def test_more_earnings_never_remove_the_activity(family, raise_by): + before = activity_eligible(simulate(family)) + raised = [{**family[0], "wages": family[0]["wages"] + raise_by}] + family[1:] + after = activity_eligible(simulate(raised)) + assert after >= before + + +@settings(max_examples=15, deadline=None) +@given( + family=st.lists(caretakers, min_size=1, max_size=2), + other_earnings=st.integers(min_value=0, max_value=60_000), +) +def test_an_unnamed_separate_filer_does_not_change_the_answer(family, other_earnings): + alone = activity_eligible(simulate(family)) + beside = simulate(family, other_adult={"employment_income": {YEAR: other_earnings}}) + assert activity_eligible(beside) == alone + + +NO_ACTIVITY = { + "wages": 0, + "business": 0, + "hours": 0, + "weeks": 0, + "student": False, + "part_time": False, + "disabled": False, +} +WORKING = {**NO_ACTIVITY, "wages": 30_000, "hours": 40, "weeks": 52} + + +@settings(max_examples=15, deadline=None) +@given(family=st.lists(caretakers, min_size=2, max_size=2)) +# A working mother and a father at home who files separately. +@example(family=[WORKING, NO_ACTIVITY]) +# An incapacitated mother and a working father who files separately. +@example(family=[{**NO_ACTIVITY, "disabled": True}, WORKING]) +def test_parent_ids_make_tax_filing_irrelevant(family): + together = activity_eligible(simulate(family, link_parents=True)) + apart = activity_eligible( + simulate(family, link_parents=True, second_files_apart=True) + ) + assert together == apart == rule_says_eligible(family) + + +def test_a_family_with_no_activity_fails_without_an_exempt_path(): + assert not activity_eligible(simulate([NO_ACTIVITY])) + + +@pytest.mark.parametrize( + "path", + [ + {"spm_unit": {"meets_ccdf_activity_test": {YEAR: True}}}, + {"household": {"is_homeless": {YEAR: True}}}, + {"child": {"receives_or_needs_protective_services": {YEAR: True}}}, + {"child": {"is_in_foster_care": {MONTH: True}}}, + ], +) +def test_each_exempt_path_qualifies_a_family_with_no_activity(path): + assert activity_eligible(simulate([NO_ACTIVITY], **path)) + + +def test_tanf_receipt_alone_is_not_an_exempt_path(): + # Under 3.111(D)(6) and 3.115(C), caretakers of children who receive basic + # cash assistance must meet the low-income activity test. + tanf = {"spm_unit": {"is_tanf_enrolled": {MONTH: True}}} + assert not activity_eligible(simulate([NO_ACTIVITY], **tanf)) diff --git a/policyengine_us/variables/gov/states/co/ccap/co_ccap_activity_eligible.py b/policyengine_us/variables/gov/states/co/ccap/co_ccap_activity_eligible.py new file mode 100644 index 00000000000..2b9d191ab9d --- /dev/null +++ b/policyengine_us/variables/gov/states/co/ccap/co_ccap_activity_eligible.py @@ -0,0 +1,107 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, +) + + +class co_ccap_activity_eligible(Variable): + value_type = bool + entity = SPMUnit + label = "Meets the Colorado Child Care Assistance Program activity requirement" + definition_period = MONTH + defined_for = StateCode.CO + reference = ( + # PDF pages 2, 31, 33-37, 39-40, 63, 68-69, 72 + "https://www.sos.state.co.us/CCR/GenerateRulePdf.do?ruleVersionId=12658&fileName=8%20CCR%201403-1#page=2", + # 2023 edition, section 3.105.1 + "https://www.sos.state.co.us/CCR/GenerateRulePdf.do?ruleVersionId=11042&fileName=8%20CCR%201403-1#page=14", + "https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-A/part-98/subpart-C/section-98.20#p-98.20(a)(3)", + ) + + def formula(spm_unit, period, parameters): + p = parameters(period).gov.states.co.ccap.activity + person = spm_unit.members + year = period.this_year + # Clip each source so a business loss can't erase wages. + earnings = np.sum( + [max_(person(source, year), 0) for source in p.earned_income_sources], + axis=0, + ) + # 3.111(G)(1)(b) and (G)(2)(d) require gross income of at least the + # federal minimum wage for each hour worked. Weeks of zero mean none were + # reported, so usual hours apply to the full year. A person with earnings + # and no recorded hours passes, since no hours can pull the rate below + # the floor. The model records one figure for weekly hours, so a job and + # a business share it; meets_ccdf_activity_test covers a qualifying job + # beside unpaid or loss-making work. + weeks = person("weeks_worked", year) + weeks_at_work = where(weeks > 0, min_(weeks, WEEKS_IN_YEAR), WEEKS_IN_YEAR) + # Pre-response hours avoid a circular dependency through labor supply + # responses. + annual_hours = person("weekly_hours_worked_before_lsr", year) * weeks_at_work + wage_floor = parameters(period).gov.dol.minimum_wage * annual_hours + works = (earnings > 0) & (earnings >= wage_floor) + # 3.111(G)(4) covers training, post-secondary education for a first + # bachelor's degree or less, and basic education. The model can't see + # the lifetime week limits. + in_activity = works | person("co_ccap_in_eligible_education", year) + # 3.103(C) limits caretakers to the parents, guardians and those in loco + # parentis who contribute to the child's welfare, and 3.111(D)(2) counts + # them whatever their tax filing. The filers who claim an eligible child + # stand in for them, joined by any parent that the child's parent ids + # name in the same SPM unit. Without parent ids, a parent who files + # separately and claims no child doesn't count, and neither does another + # adult in the home. 3.111(D)(1)(b) treats a caretaker who is + # involuntarily out of the home, as through incarceration, as absent. + eligible_child = person("co_ccap_child_eligible", period) + claims_eligible_child = person.tax_unit.sum(eligible_child) > 0 + claiming_filer = ( + person("is_tax_unit_head_or_spouse", year) & claims_eligible_child + ) + unit = spm_unit.members_entity_id + children = np.flatnonzero(eligible_child) + named_parents = [] + for parents in co_resident_parent_indices(person, year): + parent = parents[children] + resolved = parent >= 0 + same_unit = unit[np.where(resolved, parent, 0)] == unit[children] + named_parents.append(parent[resolved & same_unit]) + named_parents = np.concatenate(named_parents) + linked_parent = np.bincount(named_parents, minlength=person.count) > 0 + caretaker = (claiming_filer | linked_parent) & ~person( + "is_incarcerated", period + ) + # 3.111(D)(1)(c)(3) lets one caretaker be incapacitated when the other is + # in an activity, and 3.111(E) bars a single incapacitated caretaker with + # no activity. Disability stands in for the medical verification. + incapacitated = person("is_disabled", year) | person( + "is_incapable_of_self_care", year + ) + has_active_caretaker = spm_unit.sum(caretaker & in_activity) > 0 + no_inactive_caretaker = ( + spm_unit.sum(caretaker & ~in_activity & ~incapacitated) == 0 + ) + low_income_activity = has_active_caretaker & no_inactive_caretaker + # 3.118(A) and 3.119(D) exempt households with children placed in foster + # or kinship care under an open child welfare case. + protective_services = spm_unit.any( + eligible_child + & ( + person("receives_or_needs_protective_services", year) + | person("is_in_foster_care", period) + ) + ) + # 3.121(B)(3)(a) waives the activity during a stabilization period of at + # least 60 days. The model can't track that period, so the exemption + # lasts as long as the household's homeless flag. + homeless = spm_unit.household("is_homeless", year) + # This input covers job search (3.111(G)(3)), substance use disorder + # treatment (3.111(G)(4)(a)(3)), the launch period for a new business + # (3.111(G)(2)(e)), temporary leave (3.111(D)(8) and (9)), Child Welfare + # Child Care (3.120) and Colorado Works participants in allowable work + # activities under an individualized plan (3.116(A)). TANF receipt + # alone doesn't count: under 3.111(D)(6) and 3.115(C), caretakers of + # children who receive basic cash assistance without them must meet + # the low-income activity test. + other_activity = spm_unit("meets_ccdf_activity_test", year) + return low_income_activity | protective_services | homeless | other_activity diff --git a/policyengine_us/variables/gov/states/co/ccap/co_ccap_eligible.py b/policyengine_us/variables/gov/states/co/ccap/co_ccap_eligible.py index 4db8a20a301..bc37eed38ac 100644 --- a/policyengine_us/variables/gov/states/co/ccap/co_ccap_eligible.py +++ b/policyengine_us/variables/gov/states/co/ccap/co_ccap_eligible.py @@ -9,6 +9,7 @@ class co_ccap_eligible(Variable): "https://www.sos.state.co.us/CCR/GenerateRulePdf.do?ruleVersionId=11042&fileName=8%20CCR%201403-1#page=17", "https://www.sos.state.co.us/CCR/GenerateRulePdf.do?ruleVersionId=11042&fileName=8%20CCR%201403-1#page=19", "https://www.sos.state.co.us/CCR/GenerateRulePdf.do?ruleVersionId=11042&fileName=8%20CCR%201403-1#page=31", + "https://www.sos.state.co.us/CCR/GenerateRulePdf.do?ruleVersionId=12658&fileName=8%20CCR%201403-1#page=31", ) definition_period = MONTH defined_for = StateCode.CO @@ -23,6 +24,8 @@ def formula(spm_unit, period, parameters): "co_ccap_re_determination_eligible", period ) - return (in_entry_process & entry_eligible) | ( - in_re_determination_process & re_determination_eligible + activity_eligible = spm_unit("co_ccap_activity_eligible", period) + return activity_eligible & ( + (in_entry_process & entry_eligible) + | (in_re_determination_process & re_determination_eligible) ) diff --git a/policyengine_us/variables/gov/states/co/ccap/co_ccap_in_eligible_education.py b/policyengine_us/variables/gov/states/co/ccap/co_ccap_in_eligible_education.py new file mode 100644 index 00000000000..951712ddc92 --- /dev/null +++ b/policyengine_us/variables/gov/states/co/ccap/co_ccap_in_eligible_education.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class co_ccap_in_eligible_education(Variable): + value_type = bool + entity = Person + label = "Enrolled in education or training that counts as a Colorado Child Care Assistance Program activity" + documentation = ( + "Enrollment in training, in a post-secondary program for a first " + "bachelor's degree or less, or in basic education. Defaults to " + "full-time school or college enrollment or part-time college " + "enrollment. The model has no record of degree level, so set this to " + "false for a graduate student or a second bachelor's degree." + ) + definition_period = YEAR + defined_for = StateCode.CO + # PDF pages 39-40 + reference = "https://www.sos.state.co.us/CCR/GenerateRulePdf.do?ruleVersionId=12658&fileName=8%20CCR%201403-1#page=39" + + def formula(person, period, parameters): + # 3.111(G)(4)(a)(1) counts enrollment in training or a post-secondary + # program for a first bachelor's degree or less, with no full-time + # requirement, and 3.111(G)(4)(b) counts basic education. + return person("is_full_time_student", period) | person( + "is_part_time_college_student", period + )