Mode-3 case schema + UK battery: UKMOD/EUROMOD lane groundwork - #49
Mode-3 case schema + UK battery: UKMOD/EUROMOD lane groundwork#49vahid-ahmadi wants to merge 6 commits into
Conversation
|
Dual-gate review: NEEDS-FIXES. Blockers: (1) arbitrary misses can be blessed — |
|
All three blockers fixed in 838cc45.
Tests: Note: the battery now spans policy years {2025, 2026} — |
Review — Mode-3 case schema + UK batteryDisciplined 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 ( Should address
Suggestions
Reviewed with Claude Code assistance. |
|
All four review items addressed in a1d57d7:
Suite: 176 passed / 4 skipped; ruff clean. |
Re-review — all addressed ✅Went back through my prior notes against the new commits; everything's resolved:
Nice — no remaining concerns from my side. |
DTrim99
left a comment
There was a problem hiding this comment.
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.
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>
a1d57d7 to
8adbc93
Compare
`updated` is derived, not authored: sync_lane_feed() sets it from the max of the lane rows' own updated_at (2026-08-19) and rewrites the file. The rebase conflict resolution hand-set it to 2026-08-20, so every ingest reverts it — invisible while the DB was committed, but #74 builds the DB during collection, so the drift now fails the no-drift gate and test_app_data_copies_match_committed_data (the build rewrites data/ but not the app/public/ copy). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Post-#74 fix (9eff590). Verified the whole stack against the new DB-built-in-CI main and this branch was the one breakage — my own, from the earlier rebase: I hand-set Invisible while the DB was committed (ingest never ran during the suite); with #74 building the DB at collection it reverts every run, so it failed both new no-drift gates and Reverted to the derived value in both copies. Merged against current main locally: 295 passed / 2 skipped, no-drift clean before and after the suite. Same class of latent breakage as the PyYAML finding on #56 — old green CI was luck. |
Re-gate round 2 — closure fixed; the seam and the epistemic wiring aren't yetCleared this round: unknown JSON keys and unknown enum strings now raise (the narrow closure blocker), the synthetic merge with post-#74 main is conflict-free with no committed-DB dependency, and an in-memory replay of all feed synchronizers reproduced lanes.json byte-identically — the branch is safe against the new no-drift gates as it stands. Six findings, four blocking (each was probed, not read):
47 focused tests pass and the worktree stays clean — the schema-closure work is real progress; the remaining arc is making the invariants structural rather than conventional, and wiring mode-3 into the same descriptive/gated discipline as the rest of the DB. Happy to pair on #3's table design — it's the piece with repo-wide surface. 🤖 Generated with Claude Code |
1. The classify -> CaseResult seam is enforced, not conventional. __post_init__ now RECOMPUTES classify() and compares. A stored classification is valid only if it equals the classifier's, or if it adjudicates a row the classifier left `unclassified` — drawn from ADJUDICATABLE and carrying a writeup. Every round-2 probe now raises: 100 vs 100 as unclassified/pe_gap, a numeric mismatch as policy_scope_mismatch with nothing said, and a boolean 1 vs 0 as match_within_tolerance with a caller-supplied tolerance of 100. pe_gap and policy_scope_mismatch are two-sided: mechanical on a null side, adjudicated (and explained) on a numeric-vs-numeric row — previously only oracle_difference and rounding demanded a writeup. Tolerances now come from DEFAULT_TOLERANCES alone; from_classification no longer takes a table, and a row carrying a different tolerance is rejected. It DOES now take annotations, which is also what made every #64 calculator call raise. 2. variable_class is required (the validator cannot check a row without it, and the shipped valid-row test omitted it) and schema_version is required rather than defaulting to the current contract. Blank engine or oracle versions are rejected. 4. The two-child-limit family isolates one mechanism per pair. Three 2026 cases: `binding` (three SEPARATE births, so no exception can stand in for the limit) and `multiple-birth` (identical household, one date of birth apart) both in the registered `pre_ab2025` world, and `abolished` — byte-identical household, same year, same rates — under current law. binding/abolished attributes the abolition; binding/multiple-birth attributes the exception. The old pair used twins on both sides, so an engine that wrongly kept the limit but rightly applied the exception still paid three elements and passed, and its year-on-year delta moved ages, rates and policy year at once. Cases can now reference a registered baseline world, which is the instrument that made the same-year pair possible. 5. Identity closure reaches the edges. Regions are a closed per-country registry (region="MARS" raises), expected_focus is closed per country and may not repeat, NaN and infinity are rejected everywhere an amount is read, a boolean-class comparison may only hold 0 or 1, the battery's `schema` path must name a contract this repo defines, and a case baseline must already be registered in baselines.py. UK private-rent cases must now pin a BRMA — LHA is set per Broad Rental Market Area, so "the rent clears the cap in most Yorkshire BRMAs" was not a pinned world — and a BRMA outside its own region raises. 6. sync_lane_feed's `updated` is documented as the caller-supplied LITERAL it is, with the constraint that every caller in a build must pass the same constant, so the next caller does not reintroduce the drift. Deliberately NOT in this commit: finding 3, the case/result table, writer, exporter and build_db step, plus the epistemic columns and the status/diagnosis split that ride on them. That is the piece with repo-wide surface that Max offered to pair on, and guessing at it unilaterally is how it ends up re-litigated. SCHEMA.md and the module docstring now state the open shape explicitly rather than leaving it implied. Suite 306 passed / 4 skipped, ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
Carries the repaired #49 schema, which resolves the first blocker on its own: from_classification now accepts annotations, so calculator rows can use the required seam instead of raising, and #49's bypasses (optional variable_class, caller-supplied classifications and tolerances) are gone. 3. Dated-reading provenance is structured, not syntactic. A calculator row now carries a CalculatorReading: the reading date as a real calendar date, the exact https page, the tax year the SERVICE computed, the archive location, and sha256 digests of both the archived bytes and the canonical input vector. The oracle_version date must be the reading's own; a bare "archive:" annotation is rejected and the citation must name the reading's own archive_ref; a reading may not postdate its row; and a model oracle may not carry a reading at all. CALCULATOR_POLICY_YEARS declares what each service actually computes — gov.uk's income-tax estimator is 2026-27 only — and both the work list and every row are checked against it, which is the concrete consequence the review named. The three cases pinned to 2025 for verified 2025-26 thresholds move to 2026 (those thresholds are frozen at the same nominal values), so the assignment is honest rather than merely permitted. validate_results() checks a run against its battery: an unknown case_id and an invented variable were both accepted before. 4. Benchmark class and calibration relationship are assigned per oracle with publisher evidence, and land on the row. All seven oracles are different_model + held_out: GOV.UK describes its own tools' outputs as estimates and explicitly calls Entitledto, Turn2us and Policy in Practice "independent" calculators, so none is an authority PE is fitted to. A caller cannot relabel a calculator as authoritative. Also: the two-child multiple-birth case leaves the calculator work list. After #49 it is evaluated in the registered pre_ab2025 world, and a production calculator computes current law only — now enforced, so the abolished case is the only member of that family a calculator can answer. 2. Mode-3 persistence is still absent and is deliberately not guessed at here: it is the same table/writer/export/builder design #49 defers to the pairing Max offered, and the epistemic columns added here are written to travel on the row so they are ready for it. Suite 340 passed / 4 skipped, ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
|
Five of six addressed in
Finding 3 — taking you up on the offer to pair. The case/result table, writer, exporter and Suite 306 passed / 4 skipped, ruff format clean. |
Found in an integration review pass: uk/ukmod-cases-schema forked before #74 (the database leaving git), so it carried NO scorecard_db/build_db.py and still tracked data/scorecard.db as a committed binary. Its CI was therefore the PRE-#74 workflow — the determinism check and the no-drift gate had never run against this branch at all, so the green tick was a weaker check than it looked. Merging current main brings it under the current gates: it builds from scratch, two builds agree on content_hash, the tree is clean afterwards, and the suite is 325 passed (up from 287, because main's own tests come with it). No conflicts — main's deletion of the committed database wins over an untouched file on this side, so nothing resurrects it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
Same integration finding as uk/ukmod-cases-schema, which this branch stacks on: uk/calculator-oracles forked before #74, so it had no scorecard_db/build_db.py and still tracked the committed database. Its CI was the PRE-#74 workflow, so the determinism check and the no-drift gate had never run here either. Now under the current gates: builds from scratch, two builds agree on content_hash, clean tree afterwards, suite 359 passed (up from 321). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
|
Integration review finding — this branch had never met the post-#74 gates. It forked before #74 (the database leaving git), so it carried no Current Nothing in the branch's own code changed. |
GitHub runs a pull_request workflow from the PULL REQUEST'S OWN branch, not from the base. So a branch that forked before a gate was added keeps running the workflow WITHOUT it — and its green tick looks identical to a full one while meaning strictly less. That is not hypothetical. #74 made the database a derived artifact and added a determinism check and a no-drift gate. Branches forked before it kept the pre-#74 workflow, so neither gate had ever run against them, and they were reviewed and approved on the understanding that both had. An audit of every open PR found four in that state; two were mine (#49, #64, since fixed) and two are still open. gate-freshness.yml runs from the BASE via pull_request_target, so a stale head cannot skip it: the check is defined by main and applies to every PR regardless of what its own .github looks like. The gate set is DERIVED FROM THE BASE rather than hardcoded — it reads main's ci.yml and requires every determinism/no-drift line it finds to be present in the head's — so a gate added later is enforced on every open PR without anyone remembering to update the guard. It also refuses a branch that still tracks data/scorecard.db, whose build cannot have been from-scratch. Security: pull_request_target runs in the base repo's context, so this job NEVER checks out or executes pull-request code. It reads git metadata only and holds contents:read. A test asserts that, and asserts the guard cannot quietly become a pull_request trigger. Verified by running the exact logic against all 16 open PRs: 14 pass and exactly the two known-stale branches fail, with the reasons named. Suite 270 passed, ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
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:
countryon the case andoracle(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— withoracle_differenceandroundingreachable 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.py—CaseSpec/CaseResultdataclasses in themodels.pyfail-loud style (closed enums raise on unknown values; benefit units must partition people; owner-occupiers carry no rent;abs_diffmust reconcile), the battery loader, andclassify()— the first-pass classifier whose above-tolerance default isunclassified, 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/datamirror) —ukmod-casesadvancedregistered → 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
CaseResultrows, and publishing the miss table.Verification
PYTHONPATH=. uv run --with pytest pytest tests/ -q— 156 passed, 4 skippeduvx ruff format --check scorecard_db tests pipeline— cleandata/lanes.jsonregenerated with the feed's exact serialization; app mirror byte-identical🤖 Generated with Claude Code