Skip to content

Mode-3 case schema + UK battery: UKMOD/EUROMOD lane groundwork - #49

Open
vahid-ahmadi wants to merge 3 commits into
mainfrom
uk/ukmod-cases-schema
Open

Mode-3 case schema + UK battery: UKMOD/EUROMOD lane groundwork#49
vahid-ahmadi wants to merge 3 commits into
mainfrom
uk/ukmod-cases-schema

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Part of #41 — the schema/battery half of the UKMOD/EUROMOD mode-3 lane. Designed per #5 as the single case-level schema shared with the TAXSIM lane: country on the case and oracle (ukmod | taxsim) on the result row keep it engine-agnostic, so mode 3 gets one schema, not two.

What's here

  • sources/ukmod-cases/SCHEMA.md — the case-diff contract. A case = {case_id, description, policy_year, country, household, expected_focus, rationale} with an engine-neutral, closed household input vocabulary (people with ages/incomes/disability + carer flags, benefit-unit structure, region/tenure/rent) that each lane's connector maps to its engines' variables. A result row = {case_id, variable, pe_value, oracle_value, oracle, engine_version, oracle_version, computed_at, abs_diff, classification}. Classification is a fail-loud closed set — match_exact / match_within_tolerance / pe_gap / oracle_difference / policy_scope_mismatch / rounding / unclassified — with oracle_difference and rounding reachable only by adjudication with a writeup. Tolerances per variable class: currency £0.01/week (0.52/year annual comparisons), booleans exact.
  • sources/ukmod-cases/battery/cases.json — 14 curated UK hypothetical households, inputs + expected focus only (no fabricated expected values; both sides of every comparison come from engine runs later). Coverage: single unemployed on UC; lone parent + 2 children with LHA rent; two-child limit binding with a pre-April-2017 protected child; London benefit cap; 55% taper + work allowance; minimum income floor; Pension Credit single + couple; mixed-age couple routed to UC; Scottish bands + Scottish Child Payment; HICBC at the £70k mid-taper; NI threshold edges (£12,570 / £50,270) with salary sacrifice; carer CA + UC carer element; £100k personal-allowance taper. Round, auditable inputs with a rationale per case.
  • scorecard_db/case_diffs.pyCaseSpec / CaseResult dataclasses in the models.py fail-loud style (closed enums raise on unknown values; benefit units must partition people; owner-occupiers carry no rent; abs_diff must reconcile), the battery loader, and classify() — the first-pass classifier whose above-tolerance default is unclassified, never a flattering bucket.
  • tests/test_case_schema.py — battery loads/validates, unique country-prefixed ids, inputs-only invariant, focus coverage, classifier and result-row edge cases (28 tests).
  • data/lanes.json (+ app/public/data mirror) — ukmod-cases advanced registered → cataloged.

Out of scope (remaining for #41)

The JRC connector run itself (needs the UKMOD environment): vocabulary→UKMOD input mapping, executing the battery on both engines, appending CaseResult rows, and publishing the miss table.

Verification

  • PYTHONPATH=. uv run --with pytest pytest tests/ -q — 156 passed, 4 skipped
  • uvx ruff format --check scorecard_db tests pipeline — clean
  • data/lanes.json regenerated with the feed's exact serialization; app mirror byte-identical

🤖 Generated with Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor

Dual-gate review: NEEDS-FIXES. Blockers: (1) arbitrary misses can be blessed — match_within_tolerance/rounding/oracle_difference validate with empty annotations and no tolerance or adjudication writeup, contradicting the module contract and SCHEMA.md; (2) closed-schema gaps — benefit-unit adults/children and expected_focus aren't required to be lists, so one-character strings validate; (3) the two-child-limit case can't exercise its rationale — policy year 2026 begins 6 April, the day the limit ended, and exempting the eldest (already within the first two children) never tests a third-child exemption. Verified: no dependency on #48.

@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

All three blockers fixed in 838cc45.

# Blocker Fix
1 match_within_tolerance/rounding/oracle_difference validated with empty annotations and no tolerance or writeup CaseResult grows a tolerance field, required on (and only on) match_within_tolerance rows with 0 < abs_diff <= tolerance enforced — stored per row so a tolerance-table change can never silently re-bless history. oracle_difference/rounding are now ADJUDICATED_ONLY: they raise without a non-empty annotations writeup. annotations itself must be a list of non-empty strings. SCHEMA.md documents all of it
2 Benefit-unit adults/children and expected_focus accepted strings (iterate as characters) Both now require actual lists — person-id strings for unit members, variable names for expected_focus; a bare string raises. Pinned by tests either way
3 The 2026 two-child-limit case could not exercise its rationale (limit ended 6 April 2026; the "exempt" eldest was already within the first two elements) Replaced with an honest pair: uk-uc-two-child-limit-multiple-birth at policy year 2025 (limit in force), all three children post-April-2017 and the multiple-birth exception sitting on the third child, so the exemption strictly changes entitlement (3 elements vs 2); and uk-uc-two-child-limit-abolished — the same family in 2026, where an engine still applying the limit under-pays exactly one element. test_two_child_limit_pair pins the year split, post-2017 DOBs, and the shared-DOB construction

Tests: test_case_schema.py 25 → 37 (empty-annotation rows fail for each of the three statuses; string adults/children/expected_focus fail; tolerance bounds at both edges; zero-diff can never be match_within_tolerance). Full suite 164 passed / 4 skipped; ruff format --check clean.

Note: the battery now spans policy years {2025, 2026} — test_uk_battery_shape says why. Case ids are safe to rename since no CaseResult rows reference the old id (the connector run is still pending the UKMOD environment).

@DTrim99

DTrim99 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Review — Mode-3 case schema + UK battery

Disciplined contract PR. The closed vocabularies are enforced in code, not just SCHEMA.md prose: unknown oracle / classification / country / person-key / household-key / case-key all raise (case_diffs.py:223-291, 346-402, 447-471) and are tested (test_case_schema.py:1119-1179, 1250-1256). The "single schema shared with the TAXSIM lane" claim holds — there's no divergent taxsim-cases schema in the tree, so this is the one definition both lanes will share. Battery cases are inputs-only, each with a rationale. No blocking issues.

Should address

  • classify()CaseResult tolerance seam is untested end-to-end (case_diffs.py:405-430 vs 336-397). classify returns MATCH_WITHIN_TOLERANCE but not the tolerance it used; the caller must re-derive DEFAULT_TOLERANCES[variable_class] and thread it into CaseResult.tolerance, which re-validates. Nothing wires classify → CaseResult, so a caller passing the wrong tolerance/variable_class is uncaught. Either add one integration test or have classify also return the applied tolerance — worth doing before Official-calculator oracles (mode 3): closed-set extension, dated-reading provenance, calculator work list #64's connector lane relies on it.
  • variable_class never persisted on CaseResult (case_diffs.py:329-344) — a stored row can't be re-classified/audited for which tolerance rule applied except via the free-text variable name.

Suggestions

Reviewed with Claude Code assistance.

@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

All four review items addressed in a1d57d7:

Item Fix
classify → CaseResult seam untested CaseResult.from_classification() is now the one wiring path: it derives abs_diff, threads the exact tolerance classify judged against (only onto match_within_tolerance rows), and persists the variable class — no caller re-derives DEFAULT_TOLERANCES[...] by hand. Six end-to-end seam tests, including a custom-table case proving the stored tolerance is the one actually applied.
variable_class not persisted Added to CaseResult (closed vocabulary, validated), set by the constructor path, documented in SCHEMA.md.
No schema_version SCHEMA_VERSION = 1 on the battery file (missing/wrong version raises in load_battery) and on every result row (foreign version raises) — #64's connector lane stacks on an explicit contract version.
date_of_birth shape-only Now parsed with date.fromisoformat; 2026-13-40, 0000-00-00, and 2025-02-30 all fail, 1996-02-29 passes.

Suite: 176 passed / 4 skipped; ruff clean.

@DTrim99

DTrim99 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Re-review — all addressed ✅

Went back through my prior notes against the new commits; everything's resolved:

  • classify→CaseResult tolerance seamCaseResult.from_classification(...) is now the single wiring path (threads the exact tolerance classify judged against), covered end-to-end by TestClassifyToResultSeam.
  • variable_class persisted on CaseResult and round-tripped by the seam.
  • schema_version on the battery + every result (loader rejects mismatch), and date_of_birth now real-parsed via date.fromisoformat so 2026-13-40/2025-02-30 fail.

Nice — no remaining concerns from my side.

@DTrim99 DTrim99 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed — all prior findings are addressed with tests (the classify→CaseResult from_classification seam, variable_class persisted, schema_version, and real-date date_of_birth validation). Approving.

vahid-ahmadi and others added 3 commits August 20, 2026 12:35
Groundwork for #41, designed as the single case-level schema mode 3
shares with the TAXSIM lane (#5) — country on the case and oracle on the
result row keep it engine-agnostic.

- sources/ukmod-cases/SCHEMA.md: the case-diff contract — engine-neutral
  household input vocabulary, result-row shape, the closed fail-loud
  classification set (match_exact / match_within_tolerance / pe_gap /
  oracle_difference / policy_scope_mismatch / rounding / unclassified),
  and per-variable-class tolerances (currency GBP 0.01/week = 0.52/year;
  booleans exact).
- sources/ukmod-cases/battery/cases.json: 14 curated UK hypothetical
  households, inputs + expected focus only (no fabricated expected
  values): UC standard/housing, lone parent + LHA, two-child limit with
  the pre-April-2017 protection, London benefit cap, taper + work
  allowance, minimum income floor, Pension Credit single/couple,
  mixed-age couple routed to UC, Scottish bands + Scottish Child
  Payment, HICBC mid-taper, NI threshold edges with salary sacrifice,
  carer CA/UC interaction, and the GBP 100k personal-allowance taper.
- scorecard_db/case_diffs.py: CaseSpec/CaseResult dataclasses (closed
  enums raise on unknown values, models.py doctrine), battery loader,
  and the classify() first-pass classifier (above tolerance defaults to
  unclassified — adjudication, never a flattering bucket).
- tests/test_case_schema.py: battery validation, unique ids, inputs-only
  invariant, classifier and result-row edge cases.
- data/lanes.json (+ app mirror): ukmod-cases registered -> cataloged;
  connector run pending the UKMOD environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hild-limit cases

Three review blockers from the dual-gate pass:

1. Adjudicated misses need their paperwork: CaseResult now requires the
   judged-against tolerance on match_within_tolerance rows
   (0 < abs_diff <= tolerance, tolerance stored so table changes never
   re-bless old rows) and a non-empty annotations writeup on the
   adjudicated-only classes oracle_difference / rounding (ADJUDICATED_ONLY);
   tolerance is forbidden elsewhere and annotations must be a list of
   non-empty strings.

2. Closed-schema list types: benefit-unit adults/children must be lists of
   person-id strings and expected_focus must be a list — bare strings no
   longer validate by iterating as characters.

3. The two-child-limit case is split into an honest pair: a policy-year-2025
   multiple-birth case where the exemption sits on the third child and
   strictly changes entitlement (the limit was abolished 6 April 2026, so
   only a pre-abolition year can exercise it), plus a 2026 abolition
   counterpart of the same family where an engine still applying the limit
   under-pays one element.

SCHEMA.md documents the tightened contract; tests extended to 37 in
test_case_schema.py (suite 164 passed / 4 skipped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ma_version, real-date DOB validation

Review items from the 2026-08-18 pass:
- CaseResult.from_classification() is the one wiring path from
  classify to a stored row: derives abs_diff, threads the exact
  tolerance classify judged against, persists variable_class. Six
  end-to-end seam tests.
- variable_class persisted on CaseResult (closed vocabulary) so stored
  rows are auditable without inferring the rule from the variable name.
- SCHEMA_VERSION = 1 on the battery file and every result row; a
  mismatched or missing version raises, so breaking changes migrate
  explicitly.
- date_of_birth must be a real calendar date (2026-13-40 now fails).

Suite: 176 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi
vahid-ahmadi force-pushed the uk/ukmod-cases-schema branch from a1d57d7 to 8adbc93 Compare August 20, 2026 11:35
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Rebased onto main (post #70#73) — it had gone conflicting on three trivial spots: the lanes.json updated date (both copies kept identical) and two __all__ orderings in scorecard_db/__init__.py, all resolved as unions with no semantic change. Suite 290 passed / 6 skipped, format clean.

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.

3 participants