PolicyEngine / PolicyEngine/policyengine-us
SC and AK CCAP pool per-child provider rate caps before the lesser-of comparison
- Dominant language
- Python
- Stars
- 162
- Forks
- 212
- Avg merge
- 3d 3h
- Merged PRs (30d)
- 97
Description
## Summary
`sc_ccap` and `ak_ccap` sum every child's provider rate cap into an SPM-unit total before applying a single lesser-of comparison against household childcare expenses. Both programs set rates **per child**, so the comparison must run per child and then sum. As written, one child's unused rate headroom covers another child's above-rate charges, overpaying the benefit.
Found while reviewing #9040, which had the same defect in `tn_ccap` (fixed there by summing the per-child minima, following the `mt_ccap` pattern).
## The defect
Both states compute a genuine per-child cap in a `Person`-level variable:
- `sc_ccap_maximum_weekly_benefit` (Person) — rate varies by age group, provider type, quality level, geography, time category, plus special-needs and foster surcharges
- `ak_ccap_benefit_per_child` (Person) — authorized rate per child plus a special-needs supplement
Then the top-level variable pools them:
```python
# sc_ccap.py
maximum_weekly_benefit = add(spm_unit, period, ["sc_ccap_maximum_weekly_benefit"])
maximum_monthly_benefit = maximum_weekly_benefit * (WEEKS_IN_YEAR / MONTHS_IN_YEAR)
pre_subsidy_childcare_expenses = spm_unit("spm_unit_pre_subsidy_childcare_expenses", period)
capped_expenses = min_(pre_subsidy_childcare_expenses, maximum_monthly_benefit)
return max_(capped_expenses - copay, 0)
# ak_ccap.py
total_per_child = add(spm_unit, period, ["ak_ccap_benefit_per_child"])
pre_subsidy_childcare_expenses = spm_unit("spm_unit_pre_subsidy_childcare_expenses", period)
capped = min_(pre_subsidy_childcare_expenses, total_per_child)
return max_(0, capped - copay)
```
`min_(E1 + E2, C1 + C2)` is not `min_(E1, C1) + min_(E2, C2)` whenever one child is under its cap and the other is over it.
## Reproduction
Both cases use one infant and one school-age child at the same provider, so only the age-driven rate differs.
**SC** — urban center, quality C, 2025-01, \$19,200/yr expenses:
| | |
|---|---|
| Per-child monthly caps | \$923.00 (infant), \$715.00 (school-age) |
| Per-child monthly expenses | \$800.00 each |
| Pooled (current) | **\$1,600.00** |
| Per-child (correct) | **\$1,515.00** |
| Overpayment | **\$85.00/mo** |
**AK** — Anchorage licensed center, FT_MONTH, 2022-01, \$24,000/yr expenses:
| | |
|---|---|
| Per-child monthly caps | \$1,135.00 (infant), \$884.00 (school-age) |
| Per-child monthly expenses | \$1,000.00 each |
| Copay | \$80.00 |
| Pooled (current) | **\$1,920.00** |
| Per-child (correct) | **\$1,804.00** |
| Overpayment | **\$116.00/mo** |
The error is zero for single-child households and zero when every child is either under or over their own cap. It peaks at `(C_high - C_low) / 2` when the expense split straddles the two caps, so it grows with the age-driven rate spread.
## Suggested fix
Follow `mt_ccap`, which already does this correctly:
```python
person = spm_unit.members
cap = person("_..._per_child", period)
child_expenses = person("pre_subsidy_childcare_expenses", period)
capped_expenses = spm_unit.sum(min_(child_expenses, cap))
return max_(capped_expenses - copay, 0)
```
Ineligible children fall out on their own where the per-child rate variable carries a `defined_for` that zeroes their cap.
**Caveat.** `pre_subsidy_childcare_expenses` (Person) splits the SPM-unit total **evenly** across children, so per-child capping still cannot capture genuinely uneven per-child provider charges. It does correct the case where the *caps* differ by age category, which is the common one and what drives the reproductions above. `mt_ccap` carries the same limitation.
## Possibly affected beyond SC and AK
A grep for the same shape (`min_` against `spm_unit_pre_subsidy_childcare_expenses` alongside an `add(spm_unit, ...)` of a per-child rate) returns 17 further candidates. **These are pattern matches only — not verified, and some will be correct** (e.g. programs whose rate genuinely has no per-child dimension). Listed for triage, not as confirmed defects:
`ms_ccpp`, `ma_ccfa`, `mn_ccap`, `in_ccdf`, `ct_c4k`, `ri_ccap`, `tx_ccs`, `pa_ccw`, `nd_ccap_base_subsidy`, `nm_ccap`, `nj_ccap`, `dc_ccsp`, `hi_ccap`, `de_poc`, `az_ccap`, `id_iccp`, `wa_wccc`
Each needs its own check: does the state's rate schedule vary by child (age, provider, quality)? If yes, the lesser-of belongs per child.
## Test gap
Existing SC and AK tests pass because their multi-child cases do not straddle the caps. Any fix should add a case where one child sits under their cap and another sits over it.
Contributor guide
Assessment
This issue has not been assessed yet.