Skip to content

feat: add council-tax single-person discount#58

Merged
vahid-ahmadi merged 3 commits into
mainfrom
vahid/council-tax-spd
Jun 1, 2026
Merged

feat: add council-tax single-person discount#58
vahid-ahmadi merged 3 commits into
mainfrom
vahid/council-tax-spd

Conversation

@vahid-ahmadi
Copy link
Copy Markdown
Contributor

Summary

Closes part of #42. Households with exactly one adult (18+) now receive a 25% single-person discount on the calculated council tax — Local Government Finance Act 1992 s.11(1)(a).

sim_couple = Simulation.from_situation({
    "people":     {"p1": {"age": 35}, "p2": {"age": 33}},
    "benunits":   {"b": {"members": ["p1", "p2"]}},
    "households": {"h": {"members": ["p1", "p2"], "main_residence_value": 80_000}},
}, year=2025)
# baseline_council_tax_calculated → £2,280 (Band D, no discount)

sim_single = Simulation.from_situation({
    "people":     {"p": {"age": 35}},
    "benunits":   {"b": {"members": ["p"]}},
    "households": {"h": {"members": ["p"], "main_residence_value": 80_000}},
}, year=2025)
# baseline_council_tax_calculated → £1,710 (= £2,280 × 0.75)

What's included

Engine (src/parameters/mod.rs, src/variables/wealth_taxes.rs, src/engine/simulation.rs):

  • New single_person_discount_rate field on CouncilTaxParams (default 0.25)
  • calculate_council_tax(hh, params, is_single_adult) applies the discount when the flag is true
  • simulation.rs counts adults via Person::is_adult() (age >= 18) and passes adult_count == 1

New microdata columns: baseline_council_tax_calculated and reform_council_tax_calculated on each household row, so reform analyses can isolate the change without decomposing total_tax

Python wrapper: first-time exposure of CouncilTaxParams (the params struct existed in Rust but was never reformable from Python). Reform authors can now do:

from policyengine_uk_compiled import Parameters, CouncilTaxParams
reform = Parameters(council_tax=CouncilTaxParams(single_person_discount_rate=0.50))

Rust unit tests (3): Band D discount, Band A discount, zero-discount-rate edge

YAML policy-test cases (4) in tests/policy/council_tax.yaml: Band D two adults, Band D single adult, Band A single adult, Band D one adult + two children (children don't count for the discount)

Changelog fragment under changelog.d/added/

Caveat — baseline runs are unchanged

Net income still uses the FRS-recorded hh.council_tax amount, which already reflects whatever discount the household actually receives. The calculated value is for reform modelling. Wiring the calculated value into actual net-income computation is a follow-up — it'd require correctly handling Council Tax Reduction (CTR), exemptions, and devolved CTR variants (issues remaining under #42).

Verified locally

  • cargo test: 162 passing (159 + 3 new)
  • pytest interfaces/python/tests: 87 passing
  • python -m policyengine_uk_compiled.yaml_tests tests/policy: 29/29
  • End-to-end smoke: single-adult Band D household → £1,710 (vs £2,280 for two-adult), confirming the discount applies correctly

Stacking

vahid/council-tax-spdvahid/dla-aa-from-flags (#57) ← vahid/pip-from-flags (#56) ← vahid/lbtt-ltt (#55) ← vahid/yaml-test-harness (#54) ← vahid/parity-harness (#53) ← vahid/from-situation (#52). Seven-deep stack.

Out of scope (remaining for #42)

  • Council Tax Reduction (CTB) — needs means-tested CTR scheme rules
  • Status discounts (full-time students, severe mental impairment, apprentices)
  • Property exemptions (Class N: all-student households, Class M, etc.)
  • Devolved CTR variants (English, Scottish, Welsh schemes diverge)
  • Wiring the calculated value into actual household net income (currently only the FRS-recorded value is used)

Test plan

  • CI passes (cargo + pytest + parity smoke + YAML cases)
  • Manual sanity-check: two-adult Band D household yields £2,280; single-adult yields £1,710
  • Reform authors can override single_person_discount_rate via CouncilTaxParams

🤖 Generated with Claude Code

Households with exactly one adult (18+) now receive a 25% discount on
the calculated council tax — Local Government Finance Act 1992 s.11(1)(a).

Adds:
- `single_person_discount_rate` field on `CouncilTaxParams` (default 0.25)
- Updates `calculate_council_tax(hh, params, is_single_adult)` to apply
  the discount
- Counts adults via `Person::is_adult()` (age >= 18) in `simulation.rs`
- New `baseline_council_tax_calculated` / `reform_council_tax_calculated`
  per-household microdata columns
- First-time exposure of `CouncilTaxParams` in the Python wrapper
- 3 new Rust unit tests (band D + band A discount, zero-discount-rate edge)
- 4 new YAML policy-test cases (`tests/policy/council_tax.yaml`)

The baseline run still uses the FRS-recorded `hh.council_tax` for net
income; the calculated value is for reform modelling, where now reforms
to either band-D rate or the discount fraction take effect.

Stacked on #57.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vahid-ahmadi vahid-ahmadi force-pushed the vahid/dla-aa-from-flags branch from 85327fa to 8e4986e Compare May 29, 2026 09:17
@vahid-ahmadi vahid-ahmadi force-pushed the vahid/council-tax-spd branch from 2097c87 to 974dc21 Compare May 29, 2026 09:17
vahid-ahmadi added a commit that referenced this pull request May 29, 2026
Mirrors the existing old-SP scaling pattern for the new-SP cohort:
- If `person.state_pension > 0`: pass through, scaled by
  `(new_state_pension_weekly / baseline_new_sp_weekly)` for reform
  correctness
- Else: fall back to `new_state_pension_weekly × 52`

Previously the new-SP branch always returned the full parameter rate
× 52, ignoring any recorded amount. This over-stated SP for partial-
year claimants and broke parity for the pensioner_couple synthetic
scenario in PR #53's parity harness (£946 diff).

Implementation:
- Plumb `baseline_new_sp_weekly` through `Simulation`,
  `calculate_benunit`, `calculate_state_pension`, and
  `person_state_pension`, parallel to the existing
  `baseline_old_sp_weekly` field
- 3 new Rust unit tests (recorded-amount preserved, fallback to param
  when no record, recorded amount scales under reform)

Parity-harness impact (synthetic pensioner_couple scenario):
  state_pension     rust=23,000 py=23,000 diff=£0       (was £946)
  household_net_income           diff=£-41              (was £905)

Stacked on #58. Closes #59 (filed today as a follow-up to PR #53).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vahid-ahmadi vahid-ahmadi changed the base branch from vahid/dla-aa-from-flags to main May 29, 2026 09:20
@vahid-ahmadi
Copy link
Copy Markdown
Contributor Author

Rebased onto main — ready to merge.

Now independent on main and mergeable on its own. The single-person discount logic is correct — adults counted via the 18+ is_adult() predicate, 25% reduction applied only when exactly one adult is resident (LGFA 1992 s.11(1)(a); Band D £2,280 → £1,710), children correctly excluded. single_person_discount_rate defaults to 0.25 via serde, so existing param files need no changes. Verified after rebase: builds clean, all 5 council-tax unit tests pass, Python wrapper imports cleanly; YAML cases cover two-adult / single-adult / Band A / single-adult-with-children. One non-blocking note: "disregarded persons" (students etc.) aren't modelled, consistent with the rest of the codebase.

@vahid-ahmadi vahid-ahmadi merged commit 72ec560 into main Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant