Skip to content

DO NOT MERGE YET: Add an initial council tax reduction framework#1534

Draft
MaxGhenis wants to merge 105 commits into
mainfrom
codex/ctr-framework
Draft

DO NOT MERGE YET: Add an initial council tax reduction framework#1534
MaxGhenis wants to merge 105 commits into
mainfrom
codex/ctr-framework

Conversation

@MaxGhenis
Copy link
Copy Markdown
Collaborator

@MaxGhenis MaxGhenis commented Mar 23, 2026

Summary

  • add a jurisdiction-scoped Council Tax Reduction framework and wire simulated CTR into council_tax_benefit, council_tax_less_benefit, and household net income
  • support national Wales and Scotland CTR, English pension-age CTR, and 73 current English working-age billing-authority schemes
  • keep each local scheme in separate authority folders for parameters and variables, matching the jurisdiction-first pattern used elsewhere in PolicyEngine
  • add source-linked parameters, local edge-case YAML tests, coverage metadata, and a work queue for the remaining councils

Current CTR Scheme Coverage

  • 73 current English working-age billing authorities are implemented in this PR
  • current batch additions include Plymouth and Basildon, both from official 2026/27 council sources
  • unsupported English working-age authorities continue to fall back to reported baseline council_tax_benefit values rather than guessed local rules

Source And Modeling Notes

  • sources are primary council material where available: formal S13A scheme PDFs, current council pages, budget/adoption papers, or official no-change papers paired with the carried-forward scheme text
  • variables and parameters are jurisdiction-scoped even when formulas are similar, because councils set distinct schemes and may later move to separate jurisdiction repos
  • known source conflicts are documented in scheme_work_queue.md; for example Plymouth's adopted PDF controls over a conflicting live summary amount

Validation

  • uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml
    • latest local result: 577 passed in 177.17s
  • uv run ruff check on touched CTR variable/shared files
  • uv run ruff format --check on touched CTR variable/shared files
  • git diff --check
  • import checks for newly added authority variables and parameter trees

Latest Checkpoint

  • pushed Basildon implementation and a follow-up review-fix commit after subagent review
  • Basildon review fixes covered UC childcare deductions, SSP/SMP childcare work treatment, UC capital boundary tests, and the two-or-more-dependants top-band row
  • PR head is mergeable as of this checkpoint; CI is pending on the latest pushed commit

Paused Next Work

  • next likely councils to implement: Southend, Thurrock, Colchester, and Chelmsford
  • Southend has a current official council page plus a formal 2026/27 S13A PDF available, and is the next clean candidate

@MaxGhenis MaxGhenis marked this pull request as draft March 23, 2026 11:16
@MaxGhenis
Copy link
Copy Markdown
Collaborator Author

This draft still looks like real work, but not something to merge in its current form. It is stale, conflicting with current main, and large enough that the right next step is probably to split it into smaller PRs rather than revive this branch directly. A sensible breakdown would be: 1. core CTR variable/plumbing and netting behavior, 2. pensioner/Wales/Scotland baseline support, 3. local-authority-specific overrides and comparison scripts. If no one is planning to actively do that split soon, closing this draft would be cleaner than leaving it to rot.

@MaxGhenis MaxGhenis changed the title Add an initial council tax reduction framework DO NOT MERGE YET: Add an initial council tax reduction framework Apr 12, 2026
@MaxGhenis
Copy link
Copy Markdown
Collaborator Author

Checkpoint recap after Coventry batch (f19fac100):

  • Added Coventry's 2026/27 CTR scheme from the official council PDF. It models direct weekly excess-income support bands (80% / 65% / 40% / 20% / 0%), the GBP 16,000 capital boundary, UC assessed income/capital, pension-age UC and income-based benefit local cases, mixed-age local routing, and gross-income non-dependant deductions.
  • Added 14 Coventry YAML cases covering support-band boundaries, UC assessed income plus award, capital, non-dependant deductions, no 16-hour gate on gross-income bands, one-deduction couples, UC no-earned-income non-dependant exemption, and pension-age/mixed-age routing through the aggregate CTR variable.
  • Updated the autonomous handoff and work queue with guardrails from the review loop: positive UC assessed-income regressions, applicant/partner pension-age wording instead of household-wide shortcuts, and no non-dependant hours gate unless the non-dependant rule itself says so.

Local verification before push:

uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml -n Coventry  # 14 passed
uv run ruff check policyengine_uk/variables/gov/local_authorities/coventry/council_tax_reduction/*.py policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py
uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml -c policyengine_uk  # 672 passed
uv run python - <<'PY'
from policyengine_uk import CountryTaxBenefitSystem
system = CountryTaxBenefitSystem()
print("import smoke ok", len(system.variables))
PY
git diff --check

Fresh GitHub CI is pending on the pushed commit; PR mergeability is currently CLEAN / MERGEABLE.

Next queued source candidate in the handoff is Cotswold.

@MaxGhenis
Copy link
Copy Markdown
Collaborator Author

Checkpoint: added Cotswold CTR at bbedb5e.

Modeled from the official Cotswold 2026/27 scheme PDF:

  • weekly net-income bands by household type with 100/80/60/40/20/0 support rates
  • Band E liability cap for F/G/H
  • GBP 10,000 capital limit and part-block tariff income above GBP 6,000
  • protected-group CTB-style 20% taper instead of banding
  • UC assessed-income/capital branch using pre-disregard UC earnings plus adjusted UC award, including housing-element and transitional-protection disregards
  • non-dependant deductions with 16-hour remunerative-work gate, gross-income bands, one-deduction couple rule, and UC no-earned-income source input

Verification before push:

  • focused Cotswold YAML: 22 passed
  • full CTR YAML: 694 passed
  • ruff check on touched CTR variables/config/aggregator
  • import smoke
  • git diff --check

I also trialed the cheaper-agent workflow: a 5.4-mini scout produced a usable Cheltenham dossier, but flagged the non-dependant table as OCR-sensitive. That seems like the right split: mini/5.4 for scout and draft implementation on simple schemes, 5.5 for source-fidelity review and integration.

@MaxGhenis
Copy link
Copy Markdown
Collaborator Author

Checkpoint: added Cheltenham CTR scheme in 01e585498.

Modeled from Cheltenham Borough Council's adopted 2026/27 Appendix 9 source: household-type weekly net-income bands, Band E cap, GBP 6,000 capital limit, no tariff income, GBP 10 weekly earnings disregard, disabled-child disregard, UC assessed-income/capital branch, pension-age UC/income-based benefit local routing, childcare excess against tax credits, and gross-income non-dependant deductions before the percentage.

Source-review note: a subagent review initially found UC pre-disregard earnings, pension-age UC routing, childcare/tax-credit ordering, and non-dependant ordering test gaps. Those are fixed and the reviewer re-check came back clean.

Local verification:

  • uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml -n Cheltenham -> 20 passed
  • uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml -> 714 passed
  • uv run ruff check policyengine_uk/variables/gov/local_authorities/cheltenham/council_tax_reduction policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py -> passed
  • git diff --check -> passed
  • import smoke with CountryTaxBenefitSystem() -> 19 Cheltenham CTR variables loaded

Next queued source dossier: Bassetlaw 2026/27, banded working-age scheme with Band C cap, 95/88/65/45/25/0 support bands, GBP 16,000 capital limit, UC award/income handling, and non-dependant deductions.

@MaxGhenis
Copy link
Copy Markdown
Collaborator Author

Bassetlaw checkpoint pushed in 8fe3f47.

Added Bassetlaw's 2026/27 working-age CTR scheme from the council PDFs, with:

  • 95/88/65/45/25/0 weekly net-income bands and exact boundary tests
  • Band C liability cap for Band D-H
  • GBP 16,000 capital limit plus part-block tariff income over GBP 6,000
  • UC assessed income/capital branch, including a regression for UC income net of housing plus the UC award
  • pension-age UC local-scheme carve-out plus relevant-period and regulation 60A pensioner-protection flags
  • childcare deductions, earnings disregards, flat non-dependant deductions, UC no-earned-income exemption, and UC/non-UC couple deduction behavior

Verification:

  • WARNING:policyengine_core.scripts:Several country packages detected : policyengine_uk, policyengine_core.country_template. Using policyengine_uk by default. To use another package, please use the --country-package option.
    ============================= test session starts ==============================
    platform darwin -- Python 3.13.9, pytest-8.4.1, pluggy-1.6.0
    rootdir: /private/tmp/policyengine-uk-1534-chichester-fix
    configfile: pyproject.toml
    collected 21 items

policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml .....................

============================== 21 passed in 9.70s ============================== -> 21 passed

  • full CTR YAML suite -> 735 passed
  • ruff on Bassetlaw/shared CTR variables -> passed
  • import smoke loaded 20 Bassetlaw variables and parameter tree
  • subagent source review findings fixed; final UC housing re-review clean

Coverage doc/work queue now says 82 current English working-age billing authorities, plus Wales/Scotland national schemes.

@MaxGhenis
Copy link
Copy Markdown
Collaborator Author

CI follow-up: pushed formatter-only fixes in f873d5d and cc3b3c3 after the first Bassetlaw checkpoint exposed 1502 files already formatted failures. The latest PR head is cc3b3c3, mergeability is CLEAN/MERGEABLE, and all GitHub checks now pass, including the main Test job.

@MaxGhenis
Copy link
Copy Markdown
Collaborator Author

South Derbyshire checkpoint pushed in 70296edd7.

Added South Derbyshire's 2026/27 working-age CTR scheme from the official council PDF/current page:

  • excess-income support bands from 100% down to 0%
  • GBP 16k capital limit, GBP 6k tariff threshold with part-block rounding
  • GBP 1/week minimum award
  • UC branch using UC maximum amount, assessed income plus pre-deduction UC award, and UC-assessed capital
  • GBP 6.25/week working-age non-dependant deductions after the support percentage
  • mixed-age/pension-age routing, including WTC-closure UC pensioner protection

Subagent review found and I fixed:

  • non-dependant couples must not be under-deducted where the source says all 18+ non-dependants
  • child/young-person capital needs a source input path to avoid household savings overcounting
  • missing mixed-age no-income-related-benefit pensioner-route test

Local verification:

  • -n "South Derbyshire": 18 passed
  • full CTR suite: 821 passed
  • ruff check/format check, import smoke, and diff check passed

Coverage note updated to 87 current English working-age authorities.

@MaxGhenis
Copy link
Copy Markdown
Collaborator Author

Checkpoint after Bath and North East Somerset CTR (5f74f612a).

What changed:

  • Added Bath and North East Somerset's 2026/27 CTR scheme with jurisdiction-scoped params/vars.
  • Modeled non-UC ordinary 78% support with Band D cap, 20% taper, tariff income above GBP 6,000, and GBP 10,000 capital limit.
  • Modeled protected non-UC 100% support with GBP 16,000 capital limit.
  • Modeled UC Class F bands from the adopted PDF, including GBP 50 child increments and UC-assessed GBP 6,000 capital limit.
  • Registered the scheme in the local CTR aggregator/config and updated README, work queue, handoff, and changelog.

Verification:

  • Focused BathNES tests: 14 passed.
  • Full CTR suite: 870 passed in 269.07s.
  • Ruff format/check passed on touched CTR Python files.
  • Import smoke: import smoke ok 1483.
  • git diff --check passed.
  • Subagent source review came back clean; residual limitation is only that BathNES Class F "any other income declared" uses existing generic income inputs, with miscellaneous_income as the catch-all.
  • GitHub currently reports no checks on the branch after push, but PR mergeability is CLEAN / MERGEABLE at head 5f74f612a68f44c5121031a285a639eee6135e31.

Minimal Claude Code prompt to continue:

Continue PolicyEngine UK PR #1534 from `policyengine_uk/variables/gov/local_authorities/council_tax_reduction/agent_handoff.md`.

Work autonomously on branch `codex/ctr-framework`: encode more remaining Council Tax Reduction schemes in source-linked batches, using TDD, source review, focused verification, commits, and pushes. Stop only at a clean pushed checkpoint or a real blocker.

Copy link
Copy Markdown
Collaborator

@vahid-ahmadi vahid-ahmadi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Branch: codex/ctr-framework · 102 commits · 1,543 files · +76,022/−9

Verdict

Do not merge as-is. The architectural direction is sound and the local-authority work is generally high-quality, but the PR cannot be responsibly reviewed at this size. The split into 3 PRs proposed earlier in this thread is still the right call. The unblockable concern below makes a single-PR merge risky regardless of size.


Architecture (good)

Layering is clean and the contract is clear:

council_tax_less_benefit (household)
  └─ council_tax − council_tax_reduction          ← clamped at 0
council_tax_reduction (household)
  └─ Σ council_tax_benefit per benunit head
council_tax_benefit (benunit)
  └─ where(supported, simulated_council_tax_reduction_benunit, reported)
simulated_council_tax_reduction_benunit (benunit)
  ├─ national: England-pensioner / Wales / Scotland
  └─ local:    Σ of ~95 per-LA award variables
  • Shared helpers in _legacy.py (legacy_council_tax_reduction, local_non_dep_deductions, normal_gross_income_non_dep_deduction) cut duplication well.
  • Per-LA folder structure is consistent: <la>_council_tax_reduction.py, *_individual_non_dep_deduction.py, *_non_dep_deductions.py, optionally *_is_local_scheme.py / *_uc_applicable_income.py / *_maximum_eligible_liability.py.
  • Parameter trees are well-organised and (spot-checked) every YAML has metadata.reference with title + href to a primary council source.

Issues to address

1. Unblockable: hbai_household_net_income baseline shift

subtracts changed from council_taxcouncil_tax_less_benefit (hbai_household_net_income.py:62). This changes baseline net income for every UK household, not only the ~95 modelled LAs — for unmodelled LAs, council_tax_reduction becomes the reported-CTB sum, which previously wasn't being netted out at all. That's likely the correct HBAI methodology, but it's a silent baseline shift that needs an explicit micro-impact sanity check (CI poverty/inequality stats) before going to main. Not a CTR-framework concern per se; it'll move everything.

2. simulated_council_tax_reduction_benunit.py is brittle

The "exclude LAs that have their own pensioner scheme from the national-pensioner branch" is a hand-maintained & ~X_local_scheme chain (~40 entries). Adding a new LA in three places (LOCAL_COUNCIL_TAX_REDUCTION_VARIABLES, the fetch block, the exclusion AND) and forgetting one silently double-counts (national + local award). No assertion or schema enforces consistency. Worth refactoring to a registry/declarative table before more LAs are added.

3. locations.py enum: look-alike duplicates

Added BRISTOL, HEREFORDSHIRE, KINGSTON_UPON_HULL, DURHAM alongside existing BRISTOL_CITY_OF, HEREFORDSHIRE_COUNTY_OF, KINGSTON_UPON_HULL_CITY_OF. Also added CUMBERLAND, NORTH_NORTHAMPTONSHIRE, NORTH_YORKSHIRE, SOMERSET, WEST_NORTHAMPTONSHIRE, WESTMORLAND_AND_FURNESS (the 2023 unitary-restructure LAs — those are legitimate). The first group needs justification in the PR description; if these are ONS-naming variants the dataset emits, fine — but if a household could land in either bucket, CTR matching will silently miss.

4. programs.yaml entry is stale and missing fields

The new council_tax_reduction entry in policyengine_uk/programs.yaml says "explicit overrides for Stroud and Dudley" — actual coverage is ~95 LAs. Also missing parameter_prefix (required per CLAUDE.md). verified_years: \"2025-2025\" is suspect since most parameters key on 2026-04-01.

5. Test coverage is bimodal

960 YAML tests is healthy in aggregate, but ~18 LAs have ≤2 tests covering capital limits, tariff income, protected-group exemptions, and UC routing — each of which is a multi-branch formula. Examples: Bury/Warrington/Stockport (band-cap, 2–3 tests each), West Berkshire (custom capital boundary, 2 tests), Tendring (JSA-3-year flag, 2 tests), Basildon's is_local_scheme (multiple UC branches, sparse coverage). Recommend ≥5 tests per LA with non-trivial branching before merge.

6. Naming will trap future contributors

council_tax_benefit now means CTR. council_tax_reduction is just the household sum. The wrapping is functional but the naming inverts intuition. Consider renaming as part of the split — follow-up, not a blocker.

7. Comparison scripts (scripts/{entitledto,policyengine,turn2us}_ctr_compare.py, 1,344 lines combined)

Useful dev harnesses, but pinned into the main repo without README or CI hookup. Either document them in CONTRIBUTING/README or move to scripts/dev/ with a one-line purpose comment in each file.

8. Stray dev artefacts shipped in the package tree

scheme_work_queue.md, agent_handoff.md, scheme_encoding_guidance.md live inside policyengine_uk/variables/.... They're useful but should be in docs/ or top-level — they currently ship in the installable package.

Spot-check positives

  • Basildon main formula correctly gates on local_scheme & benunit_contains_household_head & would_claim & capital_eligible — pattern matches across sampled LAs (Coventry, Chichester, Bury, Tendring).
  • Parameter YAMLs cite specific S13A PDFs / Cabinet papers, not generic council pages.
  • legacy_council_tax_reduction correctly suppresses excess-income for working-age claimants on income-based benefits (excess_income = where(working_age & relevant_income_based_benefit, 0, excess_income)).
  • would_claim_council_tax_reduction correctly OR's the takeup behaviour flag with reported > 0 so existing claimants don't disappear from baseline.

Recommended path forward

Endorsing the earlier proposal — split into:

  1. Core CTR plumbing: shared framework files, council_tax_benefit routing change, council_tax_less_benefit cleanup, hbai_household_net_income shift — with explicit baseline-stats validation.
  2. National schemes: England-pensioner / Wales / Scotland.
  3. Per-LA overrides: in waves, with the simulated_council_tax_reduction_benunit registry refactor first so each subsequent LA PR touches one place, not three.

If it has to land as one piece, the unblockable items are #1 (baseline-impact check), #3 (enum duplicates) and #4 (programs.yaml).

MaxGhenis added 2 commits May 11, 2026 09:58
# Conflicts:
#	policyengine_uk/variables/household/income/hbai_household_net_income.py
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.

2 participants