Skip to content

Apply the small-cell rule and flat findings to the Budget contract's population rows - #643

Merged
MaxGhenis merged 4 commits into
mainfrom
population-small-cells
Oct 16, 2026
Merged

MaxGhenis merged 4 commits into
mainfrom
population-small-cells

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #642

Independent review: the third round (Opus 5.5; the Codex lanes were closed) approved ca1345e, in review-r3-out.md. b02bc59 adds only the two tests that round suggested (below).

Reproducer: tests/test_uk_budget_reform_contract.py::test_population_rows_state_each_condition_as_a_flat_finding
Reproducer: tests/test_uk_budget_reform_contract.py::test_publication_removes_every_small_count_and_nothing_else
Reproducer: tests/test_uk_budget_reform_contract.py::test_every_population_row_publishes_the_findings_that_decide_it
Reproducer: tests/test_uk_budget_reform_contract.py::test_a_total_moved_only_by_tiny_changes_in_few_records_is_withheld

Why

The UK Budget reform contract's population checks run on a 1,000-household subsample of a certified survey dataset. Their rows reported counts of records, and totals over them, in detail. The qualification ledger publishes from these reports, and raw reports are committed as test fixtures. So the rows now follow the small-cell rule the Budget tooling uses elsewhere: never publish a count from 1 to 9, as in W3's small(count) = 0 < count < 10.

The qualification ledger (#574) is the publishing control. It publishes only the fixed row fields and the top-level true/false values in detail. The raw report is not for publication: the rule here covers counts and totals, not an error's message, and not the difference between two rows' figures (two routes' rows share a subsample).

This PR does two things:

W4 asked for both.

What changes

src/policyengine/tax_benefit_models/uk/budget_reform_population.py:

  • Small-cell rule.
    • publishable_detail replaces every count from 1 to 9 in a row's detail, at any depth, with "fewer than 10". Python and NumPy integers are both covered, and booleans are left alone.
    • It raises TypeError on any type it cannot check, such as an array, a set or a non-text key, rather than passing it through.
    • In run_population_checks, such a row becomes an error row with its detail dropped, so the report does not hold and the run isn't lost.
    • NumPy floats become Python floats.
    • run_population_checks applies it to every row it returns.
    • 2027_total_change reads "withheld: fewer than 10 records" when 1 to 9 people's tax changed at all, or 1 to 9 changed by more than the 1e-6 threshold the published counts use (total_over_records).
    • For example, three people's £100 rises among seven negligible changes are still three records.
  • Flat findings. Each row's detail now states every condition it tests as a top-level boolean:
Check Findings
Baseline isolation; query order one <output>:<year>_matches per compared output (it was the list of differing names, or null)
Routes agree income_tax:2026_matches, income_tax:2027_matches (it had no detail)
April rate change 2026_unchanged, 2027_total_rises
Finite change expires income_tax:2028_matches (it had no detail)
UC standard allowance +10% standard_allowance_scaled_10_percent, no_award_lower, payments_are_award_less_deductions, no_payment_below_the_payable_floor, some_payment_higher, no_non_finite_units; plus no_payment_lower_informational, which is not a condition because payments can lawfully fall
State Pension +10% basic_and_new_scaled_10_percent, no_pension_lower, some_pension_higher

The counts stay, subject to the small-cell rule, for local reruns with data access. uc_allowance_verdict still returns raw counts, and its verdict is now all(findings).

  • The error row gives the exception's class name as reason. The ledger publishes reason; the message stays in detail.

No check, verdict or coverage changes, so CONTRACT_VERSION stays 1.1.0. Only the diagnostic detail changes, and no gate rule reads it (W4). Contract 2.0.0 (#627) will carry this shape when its full-run fixture is refreshed.

Invariants (tested)

  1. No count from 1 to 9 is published.
    • No population row, at any depth, holds an integer from 1 to 9.
    • Publication changes nothing else and is idempotent.
    • Checked by Hypothesis on 200 nested structures (test_publication_removes_every_small_count_and_nothing_else), on every row of the stubbed run, and on the two tiny-dataset runs.
  2. A row passes exactly when all its findings hold. Findings whose names end _informational are excluded.
    • Checked by Hypothesis on 60 runs of the population checks over random stubbed arrays (test_every_population_row_publishes_the_findings_that_decide_it). The runs use 1 to 12 units, with and without NaN, and some of them have lawful Universal Credit and State Pension responses, so every country check both passes and fails across the run (asserted).
    • Also checked on the fixed stubbed run and the two tiny-dataset runs.
  3. UC findings agree with their counts. Each finding equals its count's condition, such as no_award_lower being awards_lower == 0.
    • The verdict also equals its previous definition from the counts.
    • Checked by Hypothesis on 200 pairs built from the lawful pair, with up to three values changed, NaN included. Both holding and failing verdicts are drawn (asserted).

Mutants, each run against the population tests:

  • withholding by the counted changes only: caught by the tiny-changes test;
  • making no_payment_lower a condition: caught by the lawful-pair tests;
  • dropping 2027_total_rises from the April verdict: caught by the random-run property.

Tests

tests/test_uk_budget_reform_contract.py:

  • the three invariants above;
  • the exact suppression of a nested example, including a NumPy integer;
  • the withheld-total threshold (0, 1, 9 and 10 records);
  • each check's set of findings on the stubbed run, and the suppressed count and withheld total there;
  • a total moved only by sub-threshold changes in three records is withheld;
  • an array, a set or an integer key in a detail raises, and a NumPy boolean becomes a Python one;
  • the error-row expectation now includes reason.

Locally, test_uk_budget_reform_contract.py, test_uk_candidate_gates.py, test_uk_qualification_pipeline.py, test_qualification_contracts.py and test_uk_qualification_workflow.py pass on the head: 700 passed. ruff format --check . and ruff check . are clean.

Review findings and responses

  • F1 (low), a total over 1 to 9 sub-threshold changes was published: fixed, with a test.
  • F2 (low), the error text and F3 (low), cross-route differences: the docstring and this body now say the rule covers counts and totals only, that the ledger is the publishing control, and that the raw report is not for publication. The error message stays in detail for diagnosis; the ledger withholds it.
  • F4 (medium), the invariant tests: the UC differential now draws holding verdicts, and the random-run property covers invariants 1 and 2 on every row (see the mutants above).
  • F5 (low), unchecked types: publishable_detail now fails closed.
  • F6 (nit), unmarked informational finding: renamed no_payment_lower_informational.
  • R2-1 (low), a total over a few large changes among tiny ones: it is now withheld by either count (test_a_few_large_changes_among_tiny_ones_withhold_the_total).
  • R2-2 (low), two unguarded conditions: both are now tested.
    • State Pension lower == 0: test_a_state_pension_that_falls_fails_the_pension_check.
    • Routes-agree on 2026: test_routes_that_differ_only_in_2026_disagree.
    • Both mutants, and the R2-1 mutant, are caught.
  • R2-3 (nit), a fail-closed error escaping the run: it now turns that row into an error (test_a_detail_that_cannot_be_checked_turns_its_row_into_an_error).
  • R2-4 (nit), wording: fixed in the docstring and this body.
  • Round 3 (low), the unreformed isolation and query-order verdicts were unguarded: test_an_unreformed_rebuild_that_differs_fails_isolation and test_outputs_that_depend_on_query_order_fail_the_order_check now cover them. Forcing either verdict to pass is caught.
  • F7 (nit), NaN handling: the isolation rows treat NaN as equal; the routes-agree and finite-expiry rows don't. This predates the PR and is unchanged here, since changing it would change those verdicts.

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 16, 2026 15:07
…population rows

No population row now reports a count of survey records from 1 to 9 (it
reads "fewer than 10") or a total over fewer than 10 changed records,
and each row states every condition it tests as a top-level true/false
finding, so a failed check can be diagnosed from the published findings.
Fixes #642.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and test the findings on random runs

The April total is withheld by the records whose tax moved at all, not
only those past the counting threshold. publishable_detail raises on a
type it cannot check. The informational finding is named
no_payment_lower_informational. The docstring says the rule covers
counts and totals, not error text or cross-row differences, so the raw
report is not for publication; the ledger is the publishing control.

The UC differential now draws holding verdicts, from lawful pairs, and a
property runs the population checks on random stubbed arrays, lawful
responses included, checking every row's findings decide it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… row whose detail cannot be checked

A few large changes among negligible ones are still a few records, so
the April total is withheld when the changes past the threshold number
1 to 9 as well as when all changes do. A detail publication cannot check
makes its row an error instead of raising out of the run. New tests pin
routes that disagree only in 2026 and a State Pension that falls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unreformed rebuild unlike the first fails isolation, and outputs that
depend on the order they are asked for fail the query-order check; each
verdict forced to pass is now caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 16, 2026 22:21
@MaxGhenis
MaxGhenis merged commit 7ed7c6c into main Oct 16, 2026
12 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merge audit (W1, session local_37646626): merged at b02bc59, pinned with --match-head-commit. The merge commit is 7ed7c6c.

  • CI: gh pr checks exited 0 on b02bc59, with all 12 checks passing (Test 3.11–3.14, Lint, Mypy, smoke-import, changelog, release inputs). The PR was MERGEABLE and CLEAN, and not a draft.
  • Reviews: four rounds of independent Opus 5.5 review, served on Opus because the Codex lanes were closed. The reports are in ~/reviews/w1-population-small-cells/:
    • review-out.md, round 1: REQUEST_CHANGES, every item fixed;
    • review-r2-out.md, round 2: APPROVE, with its four non-blocking items done;
    • review-r3-out.md, round 3: APPROVE;
    • review-r4-out.md, round 4: APPROVE of this head, a test-only delta.
  • No CHANGES_REQUESTED review is outstanding.
  • Consumer: W4's gate needs no CONTRACT_VERSION bump (W4 confirmed). W4 will refresh tests/fixtures/uk_contract with a fresh run.

@MaxGhenis
MaxGhenis deleted the population-small-cells branch October 16, 2026 23:24
MaxGhenis added a commit that referenced this pull request Oct 17, 2026
The contract keeps 2.0.0's statutory prior law and gating extended-freeze
checks, and takes main's default capital gains response, whose checks
wait on policyengine-uk#2396. The evaluate_case fixture is recorded again
from the merged runner: against main it differs only in the extended
freeze's rows (132 gating rows for 18 pending ones), and against this
branch only in main's capital gains and blended-rate rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Apply the small-cell rule and flat findings to the Budget reform contract's population rows

1 participant